Java线程与锁
概要:线程的实现方法、 线程调度、线程状态及转换、线程安全(5种分类、3种实现方法、锁优化技术)
进程是OS进行资源分配的基本单位,线程是CPU进行任务调度的基本单位。
1、线程的实现方法
可参阅 我是一个进程#线程-码农翻身
1.1、使用OS的内核线程(Kernel-Level Thread,KLT),程序一般不能直接使用KLT而是使用KLT的高级接口——轻量级进程LWP。应用层的线程数和内核的线程数是1:1的关系。
优点:实现简单——直接调用已有的内核线程即可,不用考虑线程同步、调度等工作。
缺点:各种操作最终都交由内核线程完成,系统调用在用户态和内核态频繁切换代价高;支持的轻量级进程数量有限。
进程和线程容易混淆,进程是操作系统分配资源的基本单位,线程是CPU调度的基本单位。但在Linux内核中是没有线程这个概念的,我们常说的线程其实是轻量级进程的概念:LWP,线程概念是C库中的。
1.2、使用用户线程(User Thread,UT)实现:完全在用户态上自己实现线程,内核感知不到线程存在。许多编程语言最初使用过这种方式,但现在基本放弃了这种。用户线程与内核线程间是N:1的关系。
优点:不需要切换到内核态所以操作快速低消耗;可以支持更大的线程数量。
缺点:实现复杂,线程建立、切换、调度、同步、销毁等操作都自己实现;内核进程感知不到用户线程的存在,因此一个用户线程的阻塞会导致整个进程阻塞。
1.3、使用用户线程和轻量级进程混合实现:用户线程负责线程的创建、切换等,内核线程(轻量级进程)负责线程调度和处理器映射等。用户线程与轻量级进程(或内核线程)是M:N的关系。
优点:保留用户线程的优点如线程创建等操作快速低消耗、支持大规模用户线程并发;利用内核线程的线程调度及处理器映射等功能;内核线程可复用——处理多个用户线程。
2、Java线程实现
2.1、Java线程实现
JVM规范未限定Java线程需要使用什么线程模型实现,在不同平台下可能实现并不一致。线程模型只对线程的并发规模和操作成本产生影响,对Java程序来说这些差异是透明的。
对Sun JDK来说,JDK1.2之前基于称为“绿色线程”的用户线程实现;JDK1.2起基于OS原生线程模型实现。在Windows和Linux平台上使用一对一线程模型,在Solaris可以同时指出一对一和多对多线程模型。
20221025注:JDK19(20220920发布)GA版本提供了名为 VirtualThread 的功能,也就是上面所说的用户线程。
2.2、Java线程调度及优先级
线程调度:指系统为线程分配处理器使用权的过程。有协同式线程调度(Cooperative Threads-Scheduling)和抢占式线程调度(Preemptive Threads-Scheduling)。Java采用抢占式。
协同式:(如Lua语言)线程执行时间由线程本身控制,一个线程执行完后主动通知系统切换到另一线程。实现简单,没有线程同步问题;执行时间不可控,易阻塞如运行着的线程出问题不让出CPU。
抢占式:(如Java语言)由系统来分配线程的执行时间,线程切换不由线程本身决定。线程执行时间可控,不会有一个线程导致整个进程阻塞问题。
Java线程优先级:虽抢占式调度是系统自动完成的,但我们可以通过设置优先级“建议”系统给某些线程多分配些执行时间。
Java语言共有10个优先级,在代码中就是1 - 10 十个int常量(默认5),通过setPriority(int x)来设置。Java线程优先级通常映射到OS线程优先级(Windows下有7种、Solaris下有2^32种)。
不要依赖于Java线程优先级,因为其只是种“建议”,并不能确保高优先级的有更多执行时间,最终线程调度还是取决于OS。
2.3、Java线程状态及转换
2.3.1、线程状态
Java语言定义了5种线程状态,如下(同色块属于同一种状态):
1、新建(New) :创建后尚未通过start()启动的线程处于此状态。
2、可运行(Runable):Runable包括了OS线程状态中的Running和Ready,即处于Runable状态的线程可能正在执行,也可能处于就绪状态等待分配CPU。
3、等待:此状态的线程不会被分配CPU执行时间,等待被唤醒或等待倒计时到了后自动唤醒。分为无限等待和限时等待(wait和sleep的一个区别是前者会释放对象锁后者不会):
无限等待(Waiting):此状态的线程不会被分配CPU执行时间,需要等待被其他线程显式唤醒。
wait、join、park
以下方法让线程进入此状态:无时间参数的方法 obj.wait()、threadObj.join()、threadObj.join(0)、LockSupport.park() 。
限时等待(Timed Waiting):此状态的线程也不会被分配CPU执行时间,但过一定时间后由系统自动唤醒而不用经由其他线程显式唤醒。
wait、join、park、sleep、IO
以下方法让线程进入此状态:有时间参数的方法 obj.wait(long timeout)、threadObj.join(long timeout)、LockSupport.parkNanos(long nanos)、LockSupport.parkUntil(long deadline)、threadObj.sleep(long timeout) 。
LockSupport的park、unpark用于挂起或恢复线程,其底层最终是调用了Unsafe类的park、unpark native方法。
4、阻塞(Blocking):此状态的线程不会被分配CPU执行时间,在等待获取一个排它锁,在占有此锁的另一个线程释放该锁时此线程将结束阻塞。如程序等待进入同步区域时(如synchronized块)线程将进入此状态。阻塞的本质:最终反应到操作系统层面,就是将线程的状态变为非RUNNABLE的状态(如TASK_INTERRUPTIBLE),这样线程就不会OS进程调度(最终是线程调度)器分配CPU执行权,详情可参阅文章 阻塞的本质 。
5、结束(Terminated):线程执行结束已终止,处于此状态。
2.3.2、线程状态转换
详细的状态转换如下:(wait是Object的实例方法,调用时会释放持有的对象锁,其他则不会。各方法的区别可见 join、sleep、wait、notify等的区别-MarchOn)
3、Java线程安全
考虑线程安全的前提:各线程存在对共享数据的访问。只有存在对共享数据的访问才会有线程安全问题。
3.1、线程安全的定义
线程安全的抽象定义(《Java Concurrency In Practice》作者Brian Goetz):
当多个线程访问一个对象时,如果不用考虑这些线程在运行时环境下的调度和交替执行,也不需要进行额外的同步,或者在调度方进行任何其他的协调操作,调用这个对象的行为都可以获得正确的结果,那么这个对象就是线程安全的
即当多个线程访问一个对象时,如果不用考虑任何其它情况,调用这个对象的任何行为都会获得正确的结果,那么这个对象就是线程安全的。实际上此不易做到,在大多数场景中,将“调用这个对象的行为”弱化为“单次调用”,若还能成立则任务线程安全。
自己的话说:线程安全是在【多线程访问同一个资源】的场景下而言的——当多个线程访问共享变量时,若不用进行额外的线程间同步、多个线程执行时的调度和交替执行顺序对结果也无影响,我们就说对该变量的访问是线程安全的。比如 int a=0 然后10个线程同时去执行5次a++ ,会发现结果可能不等于50,说明这个变量的访问不是线程安全的。
3.2、Java的线程安全分类
考虑线程安全的前提:各线程存在对共享数据的访问。只有存在对共享数据的访问才会有线程安全问题。
Java中操作共享的数据分为5类:
1、不可变(immutable)对象一定是线程安全的。因为Java语言规定了如果一个不可变的对象正确地被构建出来,那么其外部可见状态永远也不会改变。两种不可变类型:
基本类型:用final修饰的即成为不可变的。
对象类型:保证对象的行为不会对自身状态产生影响即可为不可变对象。如String类、枚举类型、Number的部分子类(Long、BigInteger等,AtomicInteger等原子类则不是)等,调用它们的方法都是生成一个新的对象而不会改变原来的值。
2、绝对线程安全:绝对线程安全完全满足上述抽象定义,但要保证绝对线程安全开销太大,Java中没有绝对线程安全。即使是像Vector这样所有方法加上synchronized来同步的“线程安全”类,在多线程下有时也需要调用端做额外同步措施。
3、相对线程安全:即通常所说的线程安全,如Java中的Vector、HashTable、Collections的synchronizedCollection()等都属于此。
4、线程兼容:即通常所说的线程不安全。指对象本身线程不安全,但可以在调用端使用同步手段来保证线程安全。如Java中的ArrayList、HashMap等。
5、线程对立:指无论调用端是否采取了同步措施都无法在多线程环境中并发使用的代码。Java中有,但是很少,且应该避免出现这种代码。如Thread的suspend()、resume()方法,System.setIn()、setOut()、runFinalizersOnExit()等。
3.3、Java的线程安全实现
Java中的并发正确性保障手段主要有以下几类。
1、阻塞同步(互斥同步):悲观并发策略、重量级锁。认为共享数据一定存在访问竞争而总进行加锁。
同步和互斥的区别:同步是指多线程并发访问共享数据时,保证共享数据同一时刻只被一个(或一些,使用信号量时)线程使用 。互斥是实现同步的手段,互斥的实现方式包括临界区、互斥量、信号量等;实现同步还有通信等其他手段。
Java中的互斥同步手段:有synchronized、java.util.concurrent.locks.ReentrantLock(重入锁)等。都是互斥锁,区别:
锁机制:前者是Java语言本身提供的,自动释放;后者是JDK类库实现的,需要在finally中手动释放。
编码:前者简单,后者复杂些;前者用在方法、代码块上,后者只能用在方法内。
性能:后者效率高。JDK1.6后两者性能持平,优先使用synchronized。
功能:后者增加了 等待可中断(超时或被取消)、可实现公平锁、锁可绑定多个条件 3个功能。
底层实现:前者编译后指令加lock前缀最终代码前后变为lock、unlock,后者底层用Unsafe park方法。
优缺点:使用范围广。总进行加锁、线程阻塞唤醒需要用户态核心态转换、维护锁计数器等操作,特别是阻塞增加了性能消耗。互斥同步对性能最大的影响是阻塞的实现,挂起和恢复线程的操作都需要转内内核完成,频繁切换影响OS并发性能。
2、非阻塞同步:乐观并发策略、轻量级锁。先操作,检测到冲突时再补偿,硬件指令保证 操作和检测 两组合步骤的原子性。不是为了替代重量级锁,而是在没有多线程竞争时减少传统的重量级锁使用OS互斥量带来的性能消耗。
基于冲突检测:先进行操作,若没有其他线程争用共享数据则操作成功,否则进行冲突补偿措施如重试。有Test-and-Set、Fetch-and-Increment、Swqp、Compare-and-Swap、Load-Linked/Store-Conditional等。以Test-and-Set为例:
Java中的非阻塞同步手段:JDK1.5起提供的CAS操作——sun.misc.Unsafe类的compareAndSwapInt()、compareAndSwapLong()等方法。借助之,可以原子更新基本数据类型、原子更新引用类型、原子更新数组元素、原子更新属性值(例如 AtomicInteger。详见 Java并发包中的原子操作类)。CAS操作可以认为就是一种自旋锁。CAS实际上是利用处理器提供的CMPXCHG指令实现的,而处理器执行CMPXCHG指令是一个原子性操作。
优缺点:相比于阻塞同步不用挂起线程故性能较高。存在ABA问题(可改用互斥同步解决、也可加时间戳解决)、不可重入、自旋浪费CPU资源。
3、非同步方案:保证线程安全并不一定要同步,不涉及共享数据的方法本身就是线程安全的,有很多,如:
可重入代码:不依赖存储在堆上的数据和共用的系统资源、用到的状态量都由参数传入、不调用field可重入方法等的代码。判断可重入的原则:若一个方法的返回结果是可预测的,只要输入相同数据就能返回相同结果,则是可重入的。
线程本地存储:把共享数据的可见范围限定在本线程内达到线程安全。如java.lang.ThreadLocal类,原理:每个线程都有一个Map,类型为ThreadLocalMap,Map的key为ThreadLocal变量的hash值,Value为本线程设置的该ThreadLocal变量的值。
3.4、锁优化
HotSpot中实现了很多锁优化技术来更高效地共享数据,从而提高程序执行效率。
1、锁延迟:自旋锁与自适应锁(JDK1.4.2、JDK1.6):让请求锁的线程执行忙循环(自旋)而非放弃处理器挂起,看持有锁的线程是否很快释放锁,从而避免线程挂起恢复的切换开销。即把阻塞同步优化成非阻塞同步如Test-and-Set、Compare-and-Set。自旋不总是有利的,因为忙循环会导致处理器资源白白浪费,实际中要有自旋次数限制,如果达到次数还没获得锁则线程挂。JVM中通过-XX:PreBlockSpin调节次数,默认为10。
可以通过AtomicReference实现非公平的简单自旋锁,示例如下。实际上AtomicInteger/AtomicLong等原子类内部的CAS操作就是通过不断自循环实现的,可以认为就是一种自旋锁。
public class SpinLock { private AtomicReference<Thread> sign =new AtomicReference<>(); public void lock(){ Thread current = Thread.currentThread(); while(!sign .compareAndSet(null, current)){ } } public void unlock (){ Thread current = Thread.currentThread(); sign .compareAndSet(current, null); } }
2、锁消除:基于逃逸分析,在JVM即时编译时对一些代码上要求同步但被检测到不可能存在数据共享竞争的锁进行消除。如字符串的 “+” 操作,其在JDK1.5之前会转为StringBuffer对象的连续append()操作(JDK1.5后转为StringBuilder),append操作是同步的,由于只在一个线程内访问,所以锁会消除掉。
3、锁粗化:若一系列连续操作都对同一个对象反复加锁解锁甚至加锁操作出现在循环体中,则即使没有线程竞争,频繁地进行互斥同步也会导致不必要的性能损耗。JVM检测到这种操作时会把加锁同步范围扩展到整个操作序列外部从而只要加一次锁。如上面的append操作的锁范围扩展为第一个append前到最后一个append后。
4、轻量级锁(JDK1.6):不使用互斥量而是借助对对象头标记字的CAS操作来加锁从而避免了使用OS互斥量的开销(后者涉及到用户态内核态切换)。无竞争或少量竞争时可提高性能,竞争激烈时性能反而比重量级锁低(因为膨胀成重量级锁,此时除了互斥量的开销外还额外有CAS操作开销)。轻量级锁能提升程序同步性能的依据是“对于绝大部分的锁,在整个同步周期内都是不存在竞争的”,这是一个经验数据。轻量级锁不是用来替代重量级锁的。
5、偏向锁(JDK1.6):偏向锁“偏”向于第一个获得锁的线程,如果接下来执行过程中没有其他线程请求获取该锁,则持有偏向锁的线程将永远不再需要进行同步。与上条的轻量级锁类似,也是利用对象头标记字标记锁被一个线程持有,当该线程重入时发现仍是该线程请求锁,则直接进入临界区。可以提高有同步但无竞争的程序性能,不过更彻底:轻量级锁使用CAS操作替代同步使用的互斥量来提高无竞争情况下的性能,而偏向锁此时连CAS操作也不用做了。同样地,偏向锁也不总是有利的,如果锁被多个线程访问则此时偏向锁是多余的。
前三种在执行过程中有竞争的情况下性能能提高,后两种在实际执行过程中没有竞争的情况下性能提高
前三种不是新的锁种类(属于对重量级锁的优化),后两种是,所以对于一个对象,其对象头锁状态可能是 无锁、偏向锁、轻量级锁、重量级锁 四种,只会从前往后单向升级而不能从后往前降级。
偏向锁和轻量级锁的目的是为了避免阻塞、避免OS的介入(用户态内核态切换)
偏向锁通常只有一个线程在临界区执行;而轻量级锁可有多个线程交替进入临界区,故在竞争不激烈时稍微自旋等待会就能获得锁。
关于偏向锁、轻量级锁的简单易懂介绍可参阅这篇:公众号码农翻身的文章
4、并发控制策略(Concurrency Strategies)
以对tree的并发读写为例:
1、lock-free solution
copy-on-write:确定要write的node后copy该node并对copied node做write操作,然后以原子更新方式替换原node。最多允许一个write和多个read同时access a tree,因为多个write同时写同一node时最后一个write操作会覆盖其他write操作。
CAS:test-and-set、compare-and-swap等原子操作,由硬件指令提供原子保证。
append only(20230510补):数据只增不减,即使是删除或修改操作也是增加新数据,因此数据都是追加、不用考虑加锁操作
2、lock-based solution
coarse-grained lock:以tree为粒度进行加锁
mutex lock:排它锁,如synchronized。write和read都加锁,最多允许一个write或一个read access a tree。
read-write lock:读写锁,如ReentrantReadWrite lock。write和read都加锁,最多允许一个write或多个read access a tree。
fine-grained lock:以tree node为粒度进行加锁。可采用排它锁或读写锁。write和read都加锁,最多允许一个write access a node,此时该node能否被read access视采用的锁而定。
hand-over-hand lock:必须先锁住父节点才能锁住子节点,然后释放父节点锁并获取子节点的子节点的锁,以此方式从上到下直到锁住要write的节点。被锁节点的子树无法被其他write access,即使write的是a different node。
optimistic lock:先找到要write的node,然后对node的父node加锁,再对该node加锁。允许其他write access被锁节点的子树。
hybrid solution:综合copy-on-write和fine-grained-lock,write加锁(对被加锁加点采用copy-on-write方式进行write)、read不加锁,最多允许多个write和多个read access a node。
总结:
5、Java线程池 - ThreadPoolExecutor
详情可参阅:https://www.cnblogs.com/throwable/p/13574306.html
Java 线程池的状态:
6、参考资料
[1]《深入理解Java虚拟机——JVM高级特性与最佳实践》