本文将介绍线程安全相关知识,同时本文也会是Java虚拟机篇的最后一篇文章。在日后学习中对Java虚拟机知识有更深入的理解,将会对Java虚拟机进行补充。
在万丈高楼平地起篇简单介绍了线程、进程、程序的相关知识,也简单介绍了线程的各种状态以及相互装换的关系。在本文开始之前, 我想提出一个问题:什么是线程,它是怎么实现的?有人会回答道:线程是进程的最小单位,是CPU调度的基本单位!细节一点就是线程将一个进程的资源分成自己线程的私有资源和线程共享的公共资源。除此之外,还知道什么?
实现线程有三种方式:使用内核线程实现、使用用用户线程实现和使用用户线程加轻量级进程混合实现,下面将介绍相关知识:
内核线程
内核线程是直接由操作系统内核支持的线程,这种线程由内核来完成线程切换,内核通过操作调度器对线程进行调度,并将线程任务直接映射到相应的处理器上。
用户线程
用户线程是完全建立在用户空间的线程库上,系统内核不能感知线程的存在的实现。
程序不会直接使用内核线程,而是直接使用线程的一种高级接口——轻量级进程,也就是我们通常讲的线程。每一个轻量级进程都是由一个内核线程支持,因此每一个轻量级进程都有一个独立调度CPU的权力。假使有一个轻量级进程在系统调用过程中堵塞了,也不会影响整个进程的工作。但是,由于轻量级进程是基于内核线程实现,所有的线程操作都要由系统调用,代价相对于来说是太大,需要频繁在用户态和内核态中切换;而且每一个轻量级进程都是要消耗内存资源,因此创建轻量级进程数量是有限的,达到系统资源临界点就无法添加轻量级进程。
用户线程不需要系统内核支持,可以支持大量的线程;但是,所有的操作都没有系统参与就意味着用户程序需要做大量的工作,这些工作的逻辑处理相对于复杂,将大大增加用户程序的复杂度。
基于以上的原因,结合两者之间的优势,产生了用户线程加轻量级进程混合使用。这样一来,线程的数量可以大大增加,复杂逻辑依旧是系统来处理。
Java线程调度
协同式线程调度
线程的执行时间由线程本身控制,线程将工作完成后,主动通知系统:工作已完成,可以切换其他线程。然而这样一来,当线程出现异常,那这个线程所占用的资源一直得不到释放,一旦这种线程堆积太多进程堵塞,最后会导致系统崩溃。
抢占式线程调度
每一个线程都是由系统来分配执行时间,线程的切换不再是由线程本身决定,由于执行时间是系统控制,也就是说当一个线程执行时间超时即可释放该资源,不会导致线程异常而使得进程阻塞的问题。
线程执行时间是可以调控的,可以将线程分为不同的优先级,优先级越高越容易被系统执行。但是这种单纯分级也不是十分靠谱,其一:Java线程是通过映射到系统原生线程上来实现,线程调度最终取决于操作系统,并不是每一个操作系统都有线程优先级,也不能保证系统优先级与Java优先级一致;其二:优先级是可以被系统自行更改,对于线程来说,风险是很高的。
说完线程的基本知识(有关线程状态在前文中有,本文不再赘述),接着说一说线程安全的相关知识。
线程安全
当多个线程访问一个对象时,如果不用考虑这些线程在运行时环境下的调度和交替执行,也不需要进行额外的同步,或者在调用地方进行任何其他协调操作,调用这个对象的行为都可以获得正确的结果,那这个对象是线程安全的。
按照线程安全的“安全程度”由强至弱来排序,可以将Java语言中的操作共享数据分成五类:
不可变、绝对线程安全、相对线程安全、线程兼容和线程对立。
不可变
假设共享数据是一个基本数据类型,只需要添加final关键字就可以保证它是不可变的;如果共享数据是一个对象,那么就需要保证对象的行为不会对其状态产生任何影响才行。可以参考String类。
绝对线程安全
不管任何运行环境,调用者不用使用额外的同步措施就可实现数据的不可变。
相对线程安全
需要保证对这个对象单独操作是线程安全的,在调用的时候不需要做额外的保障措施,但是对于一些特定顺序的连续调用,就有可能需要使用额外的同步手段保证调用的正确性。
线程兼容
线程兼容是说对象本身并不是线程安全,而是通过调用正确地使用同步手段来保证对象在并发环境中可以安全使用。
线程对立
无论调用是否正确采取同步措施,都无法在多线程环境中使用的代码。
线程安全实现方法
互斥同步
互斥同步是一种并发正确保障的手段,同步是在多个线程并发访问共享数据时,保证共享数据在同一个时刻只能被一个线程使用。互斥是实现同步的一种手段,临界区、互斥量和信号量都是主要互斥实现的方式。最基本的互斥同步手段就是synchronized关键字,还可以使用JUC(后面将会有一个章节专门介绍JUC包的相关知识,如果有后文的话)包中的可重入锁(ReentrantLock)实现同步。互斥同步最主要的解决线程阻塞和唤醒所带来的性能问题。
在这里比较一下两者的功能:
synchronized:线程可重入,原生语法上的互斥锁,在同步代块前后形成monitorenter和monitorexit,自动解锁。
ReentrantLock:重入,API层面互斥锁,需要自己手动释放锁。新特性:
等待可中断:当持有锁的线程长期不释放的时候,正在等待的线程可以选择放弃等待,可以处理其他事情。
公平锁: 多个线程在等待同一个锁时,必须按照申请锁的时间顺序来依次获得锁;而非公平锁则是在锁被释放时,任何一个申请锁的线程都是有机会获得锁。synchronized中锁是非公平的,ReentrantLock默认是非公平的,可以通过带布尔值的构造函数使用公平锁。
锁绑定多个条件:是一个ReentrantLock对象可以同时绑定多个Condition对象。
非阻塞同步
基于冲突检测的乐观并发策略,简单来说就是,先操作,假定在操作前并没有其他线程进行数据操作,操作成功;如果已经有其他线程先操作了共享数据,那就使用自旋锁不断重试,直到成功为止。(可能会出现CAS问题,在JUC篇章将解释这个问题,同时也会对volatile关键字再一次分析,如果还有下篇的话)
无同步方案
保证线程安全,并不是一定要进行同步,两则没有因果关系。如果一个方法不涉及共享数据,自然是无需同步,天生就是线程安全。
锁优化
为了在线程之间更加高效地共享数据,解决竞争问题,提高效率,出现了各种各样的锁优化技术。
自旋锁与自适应自旋
多个线程并发执行,为了让线程等待,需要让线程执行一个忙循环,不断询问锁释放释放。自旋锁并不能代替阻塞,自旋锁本身避免了线程的切换开销,但是它是占用了处理器时间的。这就带来一个弊端:性能会随着占用处理器时间而变化。如果占用时间短,自旋效果非常好;而占用时间长,自旋效果不好,白白浪费处理器时间。
JDK1.6引入了自适应自旋锁,自适应意味着自旋时间不会是固定,而是在同一个锁的自旋时间以及锁拥有这的状态决定。
锁消除
锁消除是指虚拟机即时编译器在运行时,对一些代码上要求同步,但是被检测到不可能存在共享数据竞争的锁进行消除。锁消除主要依据是来源于逃逸分析的数据支持,无法逃逸那么就认为是线程私有,不用加锁。
锁粗化
将同步代码作用范围限制尽量的小——在共享数据的实际作用域才进行同步似乎是共识,但是如果系列代码逻辑都是连在一块的,可以考虑将加锁同步范围扩大,这就是锁粗化。
轻量级锁
在没有多线程竞争的前提下,减少传统的重量级锁使用操作系统互斥量产生的性能消耗。Mark Word 存储对象的运行时数据,在回收文章中提及过数据的组成、例如魔数等,Mark Word 被设计成一个非固定的数据结构以便于在有限的空间存储尽可能多的信息,根据对象状态复用存储空间。虚拟机使用CAS操作将对象的Mark Word更新,更新成功获得这个对象的锁;不成功说明对象已经被其他线程占用,线程进入阻塞状态。注意:使用轻量级锁通过CAS加锁以及解锁。
偏向锁
在线程无竞争将同步消除,这个锁将会偏向于第一个获得它的线程,在进程执行过程中,没有其他线程获得该锁,那么持有偏向锁的线程永远不用再进行同步操作。
本文要是有遗漏的地方,我将在评论区给出,同时若读者发现本文不当之处也请在评论区指出,共同研究。