1.为什么需要多线程
1) 更快的速度: 使用额外的处理器 提高了吞吐量 并发提高在单处理器上的性能
2) 更小的上下文切换开销: 协作多线程上下文切换的开销比进程抢占系统要低廉很多
进程上下文切换:进程是由内核来管理和调度的,进程的切换只能发生在内核态。所以,进程的上下文不仅包括了虚拟内存、栈、全局变量等用户空间的资源,还包括了内核堆栈、寄存器等内核空间的状态。
因此,进程的上下文切换就比系统调用时多了一步:在保存内核态资源(当前进程的内核状态和 CPU 寄存器)之前,需要先把该进程的用户态资源(虚拟内存、栈等)保存下来;
而加载了下一进程的内核态后,还需要刷新进程的虚拟内存和用户栈。每次上下文切换都需要几十纳秒到数微秒的 CPU 时间。
线程上下文切换:如果切换的线程属于同一个进程。此时,因为虚拟内存是共享的,所以在切换时,虚拟内存这些资源就保持不动,只需要切换线程的私有数据、寄存器等不共享的数据(程序计数器/栈/局部变量)。
3) 更快的通信:进程有自己的变量 线程共享数据 共享变量->通信容易 线程更轻量级
多线程的问题:
1)没有同步,操作顺序不可预测。
2)活跃性问题:时序造成的死锁/饥饿/活锁
3)性能问题:频繁的上下文操作会造成极大的开销。
当并发执行累加操作不超过百万次时,速度会比串行执行累加操作要慢。那么,为什么并发执行的速度会比串行慢呢?这是因为线程有创建和上下文切换的开销。
减少上下文切换的方法有无锁并发编程、CAS算法、使用最少线程和使用协程
2.线程模型
1)线程实现:在linux里面,线程和进程没有什么区别,唯一的就是在地址空间,线程的切换虚拟内存空间依然是相同的,但是进程切换是不同的。
java则提供的线程实现方法都是native的,因为不同的硬件和操作系统提供线程调度方式并不尽相同,所以java没用采用和平台无关的统一手段来实现。
一般有使用内核线程实现,使用用户线程实现,使用用户线程加轻量级进程混合实现三种方式。
使用内核线程实现
内核线程KLT 直接由内核支持的线程,内核线程的高级接口,轻量级进程(LWP);轻量级进程就是我们所讲的线程,这种轻量级进程与内核线程之间1:1的对应关系。
轻进程(LWP:Light Weight Process)。轻进程可以理解为内核线程,它位于用户层和系统层之间。系统对线程资源的分配、对线程的控制是通过轻进程来实现的,一个轻进程可以控制一个或多个线程。
CPU的时间片调度是依赖LWP的。
优点:
内核线程支持,每个轻量级进程被系统调用阻塞,不会影响整个进程继续工作。
进程中的一个线程被阻塞了, 内核能调度同一进程的其它线程占有处理器运行。
多处理器环境中, 内核能同时调度同一进程中多个线程并行执行。
内核自身也可用多线程技术实现, 能提高操作系统的执行速度和效率。
问题:
基于内核线程,各种线程操作都需要系统调用,代价高。
每个轻量级的进程都需要一个内核线程来支持,需要消耗一定的内核资源。
由内核进行调度。应用程序线程在用户态运行, 线程调度和管理在内核实现, 在同一进程中, 控制权从一个线程传送到另一个线程时需要模式切换,系统开销较大;
使用用户线程实现
狭义的用户线程是用户线程的建立,同步,销毁完全在用户态中进行。不需要用户态/核心态切换,速度快,操作系统内核不知道多线程的存在,
因此一个线程阻塞将使得整个进程(包括它的所有线程)阻塞。进程和用户线程之间1:N
优点:
切换由用户态程序自己控制内核切换,不需要内核干涉,少了进出内核态的消耗。
可以在不支持线程的操作系统中实现。能运行在任何OS上, 内核在支持ULT方面不需要做任何工作;
问题:
多核处理器很难讲线程映射到其他处理器上,单线程阻塞会造成该进程阻塞。
用户线程加轻量混合线程
既存在用户线程,也存在轻量级进程。用户线程还是完全建立在用户空间中,因此用户线程的创建、切换、析构等依然廉价,可以支持大规模的用户线程并发。
操作系统提供支持的轻量进程作为用户线程和内核线程之间的桥梁,用户线程的系统调用要通过轻量级线程来完成,大大降低了进程阻塞的风险。用户线程和轻量级进程比是N:M多对多的关系。
java在jdk 1.2之前基于用户线程实现,在1.2之后,基于操作系统的原生线程模型来实现,在每个平台上都不尽相同,比如在windows和linux下都是采用一对一的线程模型实现
3.线程安全
并发编程的本质是为了保证线程安全。
1)线程的安全性是什么
A class is thread-safe if it behaves correctly when accessed from multiple threads, regardless of the scheduling or interleaving of the execution of those threads by the runtime environment, and with no additional synchronization or other coordination on the part of the calling code.
当多个线程访问一个对象时,如果不用考虑这些线程在运行时环境下的调度和交替执行,也不需要进行额外的同步,或者在调用方进行任何其他的协调操作,调用这个对象的行为都可以获得正确的结果,那这个对象是线程安全的
2)线程安全由强到弱:
不可变:保证对象行为不会对对象产生影响的对象 String/Long/BigInteger
绝对线程安全:有状态 无论怎么访问 都能保证线程安全 Random/ConcurrentHashMap
相对线程安全:对象单独调用是安全的 特定的连续顺序调用,就需要额外的手段。一个线程遍历Vector,另外一个线程删除Vector中的一个元素,会导致什么问题?有可能在read方法中抛出ArrayIndexOutOfBoundException
线程兼容:线程本身不是线程安全,但可以在调用端使用正确的同步手段。 Collections.synchroized
线程对立:无论是否采用同步手段,都无法在多线程环境中使用。Thread 的suspend和resume
3)如何判断线程安全
JSR-133内存模型要求使用happens-before的概念来阐述操作之间的内存可见性。在JMM中,如果一 个操作执行的结果需要对另一个操作可见,那么这两个操作之间必须要存在happens-before关系。
常见的happens-before规则
程序顺序规则:一个线程中的每个操作,happens-before于该线程中的任意后续操作。
监视器锁规则:对一个锁的解锁,happens-before于随后对这个锁的加锁。
volatile变量规则:对一个volatile域的写,happens-before于任意后续对这个volatile域的 读。
传递性:如果A happens-before B,且B happens-before C,那么A happens-before C。
4)为什么会有线程安全问题
多线程共享变量造成数据不一致影响正确性
由于JMM Java内存模型,会造成共享变量可见性问题。
Java内存模型:
所有变量都储存在主内存中
每个线程有自己的本地内存,本地内存保存了主内存的副本
线程对变量的操作都必须在本地内存中进行,不能读写主内存中的
详情参考JSR-133:JavaTM内存模型与线程规范 http://ifeve.com/wp-content/uploads/2014/03/JSR133%E4%B8%AD%E6%96%87%E7%89%881.pdf
4.实现线程安全的工具
高层次:使用编程模型的改变:Actor 协程模型/函数式编程模型(函数式语言中没有状态,计算结果也不依赖状态)
低层次:使用Java的并发编程机制
Java并发编程:
1) 保证可见性:volatile(Java内存模型决定):
volatile的主要作用:
保持线程之间的可见性:
编译成汇编会添加 Lock前缀,功能有
将当前处理器缓存行的数据写回到系统内存。
这个写回内存的操作会使在其他CPU里缓存了该内存地址的数据无效。(缓存一致性协议) -> A-32处理器和Intel 64处 理器使用MESI(修改、独占、共享、无效)控制协议去维护内部缓存和其他处理器缓存的一致性。
volatile常见场景:确保自身状态的可见性/确保引用对象的状态可见性/标识重要程序生命周期事件的发生。
防止指令重排
填满缓冲区 防止伪共享
2)互斥同步(从低级到高级):
互斥同步的特点:
如果使用阻塞,需要线程在内核和用户切换,更多时间和代价,不占用CPU时间片。
本质是悲观策略,适用于竞争比较多的情况。
synchronized(低级):
synchronized是Jvm防止资源冲突的内置支持,注意作用域的设置 锁的位置在对象头上。
Java设计者以不精准的方式采用了监视器,每一个对象有一个内部的锁和内部的条件,如果一个方法调用了synchornized关键字声明,表现的就像一个关键字方法。对应字节码中monitorenter和monitorexit指令。
synchronized是可重入的:操作单元是线程不是调用。
锁一共有4种状态,级别从低到高依次是:无锁状态、偏向锁状态、轻量级锁状 态和重量级锁状态,这几个状态会随着竞争情况逐渐升级。锁可以升级但不能降级。
Lock(低级):
Lock提供了无条件,可轮询,定时,可中断,可重入的锁获取操作。
配合try-finally使用
可中断的锁获取操作能在可取消的操作中加锁。
非公平锁高于公平锁:公平锁的实现机理在于每次有线程来抢占锁的时候,都会检查一遍 有没有等待队列.非公平锁在实现的时候多次强调随机抢占,与公平锁的区别在于新晋获取锁的进程会有多次机会去抢占锁。如果被加入了等待队列后则跟公平锁没有区别。
当需要可定时/可轮询/可中断的获取操作/公平队列/非块结构的锁使用ReentranLock,否则优先使用synchronized。
锁的优化手段:
锁粗化:把很多次锁的请求合并成一个请求,以降低短时间内大量锁请求、同步、释放带来的性能损耗
锁消除:编译器级别的锁优化方式,代码不需要加锁。逃逸分析->锁消除。
锁对象可以有一个或多个相关条件对象 newCondition 方法获得一个条件对象
JUC中的并发组件(中级):
ConcurrentHashMap/ConcunrrentSkipListMap/CopyOnWriteArrayList CopyOnWriteArrayList 写入导致创建底层数组副本->免锁容器
BlockingQueue:ArrayBlockingQueue/LinkBlockingQueue/PriorityBlockingQueue
Java的队列同步器AQS:基于AQS构建的同步器类中,包括各种形式的获取操作和释放操作。AQS负责管理同步器中的状态。管理了整数状态信息。为了支持条件队列的锁,AQS提供了机制来构造与同步器相关的条件变量。
Latches(中级):闭锁延迟线程进度直到到达中止状态,闭锁到达结束状态之前是关闭的。CountDownLatch . 同步状态保存计数值 计数值递减为0 解除所有阻塞
FutureTask(中级):也可用作闭锁, Future.get的行为取决于任务的状态。Future.get 类似闭锁的语含义 AQS维护状态 运行/完成/取消
Semphore(中级):控制访问特定资源的操作数量,同时指定操作数量。tryRelaseShared增加索引计数 到达条件解除阻塞操作
Barriers(中级):栅栏锁,所有线程必须同时到达栅栏位置,才能继续执行。闭锁用于等待事件,栅栏用于等待线程。
Executors(高级):
提供了标准方法吧任务的提交过程和执行过程解耦,基于BlockingQueue的生产者消费者模式。
灵活制定执行策略 数量/优先级/拒绝策略/执行任务的前后的操作
四种线程池:
newFixedThreadPool
newCachedThreadPool
newSingleThreadExecutor
newScheduledThreadPool
线程池执行基本流程
3)非阻塞同步:
非阻塞同步的特点:乐观锁,适用于冲突不太多的场合,需要硬件指令支持。
没有线程切换开销,代价小。占用CPU时间,冲突频繁的话
会浪费CPU资源,适用于冲突较少的情况。
Atomic:Atomic Variables 使用CAS
大量线程访问变量-> LongAdder/LongAccumulator类 多个加数总和为当前的值 多个线程更新不同的加数
LongAccumlator加入新值 accumulate 调用get获得值
CAS机制:典型非阻塞线程安全方法,有ABA问题,可以使用基于版本的指令实现。
4)安全的线程模型
代码本身可重入 无状态的对象一定是线程安全的。
线程本地存储ThreadLoca 必须回收自定义的ThreadLocal变量,尤其在线程池场景下,线程经常会被复用, 如果不清理自定义的 ThreadLocal 变量,可能会影响后续业务逻辑和造成内存泄露等问题。 尽量在代理中使用 try-finally 块进行回收。
5.补充
1.interuput 方法时 线程的中断被置位 线程不断检查中断状态是否置位
2.线程有优先级 默认是5 不可依赖
3.并发工具优先于wait/notify
4.使用线程安全的类AtomicLong来管理计数状态,如果是 JDK8,推荐使用 LongAdder 对象,比 AtomicLong 性能更好(减少乐观 锁的重试次数)。
参考:
《Java核心技术》
《Thinking in Java》
《Java并发编程实践》
《Java并发编程艺术》
《Effactive Java》