线程安全
1. Java语言中的线程安全
按照线程安全的“安全程度”由强至弱排序,可将Java中各种操作共享数据分为以下五类:不可变、绝对线程安全、相对线程安全、线程兼容和线程对立。
- 不可变:JDK1.5,Java内存模型被修正之后不可变的对象一定是线程安全的,一个不可变的对象被正确地构建出来(没有发生this引用逃逸的情况),那其外部的可见状态永远也不会改变。
- 绝对线程安全:在Java API中标注自己是线程安全的类大多数不是绝对的线程安全,比如Vector是一个线程安全的容器,但是并不意味着使用它的时候就不需要同步手段了,在多线程环境下还是有线程安全问题。
- 相对线程安全:就是通常意义上所讲的线程安全,对这个对象单独的操作是线程安全的,对一些特定顺序的连续调用就可能需要在调用端使用额外的同步手段来保证调用的正确性。
- 线程兼容:对象本身不是线程安全的,可以通过调用端正确的使用同步手段来保证对象在并发环境中安全的使用,通常所说的线程不安全就是线程兼容。
- 线程对立:不管调用段是否采取了同步措施,都无法在多线程中并发使用。如果有两个线程同时持有一个线程对象,一个尝试去中断线程,一个尝试去恢复线程,如果并发进行的话,无论调用时是否进行了同步,目标线程都存在死锁风险。(如果Thread类的suspend中断的线程就是即将执行resume的线程,就必定会产生死锁,所以已经废弃了)常见的线程对立操作还有System.setIn、System.setOut和System.runFinalizersOnExit等。
2. 线程安全的实现方法
这一节主要是讲虚拟机如何实现同步与锁。
- 互斥同步
Java中最基本的互斥同步手段就是synchronized关键字,编译之后会在同步块前后分别形成monitorenter和monitorexit这两个字节码指令,这两个字节码指令需要一个reference类型的参数,来指明要锁定和解锁的对象。如果synchronized明确指定了对象参数,那就是指定的,负责就根据synchronized修饰的是实例方法还是类方法,去取对应的实例或者Class对象来作为锁对象。
虚拟机规范规定:执行monitor指令时,首先尝试获取对象的锁,如果这个对象没被锁定或者当前线程已经有了这个对象的锁,把锁的计数器加1(这一点支持了可重入),相应的,在执行monitorexit指令时会将锁计数器减1,当计数器为0的时候锁就被释放了。
互斥同步会导致线程的阻塞和重新执行,这种切换需要系统内核支持,开销大,是一个重量级的操作。
除了synchronized之外,还有一种JUC下的ReentrantLock工具,基本用法上,ReentrantLock与synchronized很相似,只是写法上有区别。ReentrantLock增加了一些高级功能:等待可中断、可实现公平锁以及锁可以绑定多个条件。
- 等待可中断:当尺持有锁的线程长期不释放锁的时候,等待的线程可以选择放弃等待,改为处理其他事情。
- 公平锁:多个线程在等待同一个锁的时候,必须按照申请锁的时间顺序来依次获得锁。ReentrantLock默认也是不支持公平锁的,可以通过构造函数设置。
- 锁绑定多个条件是指一个ReentrantLock可以同时绑定多个Condition对象,而synchronized只能通过wait、notify和notifyAll使用一个隐含的条件,而ReentrantLock只需多次调用newCondition即可。
因为JDK1.6针对并发做了优化,所以synchronized和ReentrantLock的性能以及一样了,如果不需要ReentrantLock的高级功能,那么推荐使用synchronized
2. 非阻塞同步
基于冲突检测的乐观并发策略,也就是先操作,如果没有其他线程争用共享数据,那操作就成功,如果共享数据有争用,产生了冲突,那就再进行其他的补偿措施(最常见的补偿措施就是不断重试,直到成功为止)。这种乐观并发策略的许多实现都不需要把线程挂起,因此这种同步操作被称为非阻塞同步。
这个冲突检测是在硬件指令集发展之后才有的,在检查和操作变成一条指令的原子操作之后才有非阻塞同步的基础,如下是几种多次操作通过一条指令完成的情况:
- 测试并设设置(Test-and-Set)
- 获取并增加(Fetch-and-Increment)
- 交换(Swap)
- 比较并交换(Compare-and-Swap,下文称CAS)
- 加载链接/条件存储(Load-Linked/Store-Condition)
前三条是上个世纪就有的,后两条是现代处理器新增的。其中的CAS操作(需要三个操作数,内存位置V,旧预期值A和新预期值B,当且仅当V符合旧预期值A时,处理器用B更新V的值,否则不更新,无论如何都会返回V的旧值)在JDK1.5之后才在Java程序中可用,该操作有Unsafe类中的compareAndSwapInt()和compareAndSwapLong()等几个方法包装提供。
如果不使用反射,我们只能通过其他的Java API来间接使用,例如JUC中的整数原子类,compareAndSet和getAndIncrement,都使用了Unsafe类中的CAS操作。这种CAS操作有个漏洞:如果一个变量初次读取时是A,赋值的时候检查也是A,它有可能已经被另一个线程改过然后又改回来了,但是这个漏洞无伤大雅。
3. 无同步方案
有些代码天生线程安全。
- 可重入代码,也叫纯代码,可在任何时刻中断,然后从别的地方返回继续执行的时候还能正确执行的代码。这种代码天生线程安全。
- 线程本地存储:如果能保证两部分代码的共享数据是在同一个线程中执行,那么无须同步也能保证线程之间不会出现数据争用问题。例如经典的Web交互模型中的“一个请求对应一个服务器线程”,很多都是自己线程使用。如果一个变量要被某个线程公用,那么可以使用Thread对象中的ThreadLocalMap对象。
3. 锁优化
JDK1.6实现了很多锁优化技术:适应性自旋、锁消除、锁粗化、轻量级锁、偏向锁等
- 自旋锁与自适应自旋锁
如果物理机有一个以上的处理器,能让两个或以上的线程并行执行,我们就可以让后面请求锁的线程“稍等一会儿”而不放弃CPU的执行时间,看看持有锁的线程是否很快就会释放锁,为了让线程等待,我们只须让线程执行一个忙循环(自旋),这就是自旋锁。
如果稍等一会儿就能得到锁,那么就不需要转入内核态挂起了,所以如果自选等待的时间非常短的话,稍等一会儿的策略是非常值得的。自旋有时间限度,如果自旋次数超过了限定的次数(默认10次)仍然没有获得锁,那么就要挂起,让出自己的CPU执行权。
在JDk1.6中引入了自适应的自旋锁,意味着自旋的时间不在固定了,由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。如果在同一个锁对象上,自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行中,那么虚拟机就会认为这次自旋也很有可能再次成功,进而它将允许自旋等待持续相对更长的时间,比如100个循环,另一方面,如果对于某个锁,自旋很少成功获得过锁,那么以后在获取这个锁的时候可能省略掉自旋过程,以避免浪费处理器资源。 - 锁消除
锁消除是虚拟机即时编译器在运行时,对一些代码上要求同步,但是被检测到不可能存在共享数据竞争的锁进行消除,锁消除的主要判定依据来源于逃逸分析的数据支持,如果判断到一段代码中,堆上的所有数据都不会逃逸出去被其他线程访问到,那就可以把它们当作栈上数据对待,认为它们是线程私有的,同步加锁无须进行。
锁消除是因为锁不仅仅是Java程序员自己加的,有很多地方会隐藏着很多自动加的锁,锁消除主要是针对这些无用的锁进行的优化。
-
锁粗化
一般都要求将同步块的范围限制的尽可能的小,但是如果一系列连续操作都对同一个对象反复加锁和解锁,甚至加锁操作出现在循环体中,虚拟机如果探测到有这种情况,就会把加锁同步的范围扩大(粗化)到整个序列的外部,这样只需要加锁一次就可以了。 -
轻量级锁
轻量级锁是JDK1.6加入的新型锁机制,这里的轻量是相对于使用操作系统互斥量实现的传统锁而言的。轻量级锁的意义在于,在没有多线程竞争的前提下,减少传统的重量级锁使用操作系统互斥量产生的性能消耗。
要理解轻量级锁和后面的偏向锁,需要先了解HotSpot虚拟机给一个对象加锁的机制。以在堆内存中存储的对象的内存结构来说,对象头(Object Header)分为两部分信息:
- 用于存储对象自身的运行时数据,如哈希码、GC分代年龄等,这部分数据长度在32位和64位虚拟机中分别位32个和64个Bits,官方称为“Mark Word”,这是实现轻量级锁和偏向锁的关键。
- 另一部分用于存储指向方法区对象类型数据的指针,如果是数组对象的话,还会有一个额外的部分用于存储数组长度。
这个对象头里面有2个Bits是存储标志位的,所以对象头有五种情况(书上就是这么写的我也不懂为什么):
| 存储内容 | 标志位 | 状态 |
|---|---|---|
| 对象哈希码、对象分代年龄 | 01 | 未锁定 |
| 指向锁记录的指针 | 00 | 轻量级锁定 |
| 指向重量级锁的指针 | 10 | 膨胀(重量级锁定) |
| 空,不需要记录信息 | 11 | GC标记 |
| 偏向线程ID、偏向时间戳、对象分代年龄 | 01 | 可偏向 |
知道了对象的内存分布,在轻量级锁执行的时候,当代码进入同步块,如果此对象没有被锁定(锁标志位为“01”的状态),虚拟机首先将在当前线程的栈帧中建立一个名为锁记录的空间,用于存储锁对象目前的Mark Word的拷贝(官方把这份拷贝加了一个Displaced前缀,即Displaced Mark Word)。
拷贝完之后,虚拟机将使用CAS操作尝试将对象的Mark Word更新为指向锁记录的指针,如果更新动作成功了,那么这个线程就拥有了该对象的锁,并且对象Mark Word的锁标志位(Mark Word的最后两个Bits)将转变成“00”,即表示此对象处于轻量级锁定的状态。如果更新失败了,虚拟机会首先检查对象的Mark Word是否指向当前线程的栈帧,如果是就说明当前线程已经拥有了这个对象的锁,可以直接进入同步块继续执行,否则(也就是当前线程的栈帧中没有锁记录)说明这个对象已经被其它的线程抢占了。如果有两个以上的线程争用同一个锁,那轻量级锁就不再有效,要膨胀为重量级锁,锁标志状态值变为“10”,后面等待锁的线程也要进入阻塞状态。
加锁是用的CAS操作,解锁也是。如果对象的Mark Word任然指向着线程的锁记录,那就用CAS操作把对象当前的Mark Word和线程中复制的Displaced Mark Word替换回来,如果替换成功,整个同步过程就完成了,如果替换失败,说明有其他线程尝试过获取该锁,那就要在释放锁的同时,唤醒被挂起的线程。
轻量级锁提升程序同步性能的依据是“对于绝大部分锁,在整个同步周期内都是不存在竞争的”,如果没有竞争,CAS操作避免了使用互斥量的开销,但是如果存在竞争,除了互斥量的开销外,还额外发生了CAS操作,因此在有竞争的情况下,轻量级锁会比传统的重量级锁更慢。
- 偏向锁
偏向锁的目的是消除数据在无竞争情况下的同步原语,进一步提高程序运行性能。如果说轻量级锁是在无竞争的情况下使用CAS操作去消除同步使用的互斥量,那么偏向锁就是在无竞争的情况下把整个同步都消除掉,连CAS操作都不做了。
偏向锁会偏向于第一个获得它的线程,如果在接下来的执行过程中,该锁没有被其他的线程获取,则持有偏向锁的线程将永远不需要再进行同步。如果当前虚拟机启用了偏向锁(JDK1.6默认是启用的),那么,当锁对象第一次被线程获取的时候,虚拟机将会把对象头中的标志位设置为“01”,即偏向模式,同时使用CAS操作把获取到这个锁的进程的ID记录在对象的Mark Word中,如果CAS操作成功,持有偏向锁的线程以后每次进入这个锁相关的同步块时,虚拟机都可以不再进行任何同步操作。
当有另外一个线程获取这个锁的时候,偏向模式结束,如果锁对象正在被锁定,就变成轻量级锁定(标志位“00”),如果锁对象没有被锁定,就变成未锁定状态(“01”)。之后的同步操作和轻量级锁一样。(我没懂,另一个线程一看加了偏向锁正在锁定,然后变成轻量级锁之后,这个锁也没有释放啊,这不直接就膨胀成重量级锁了吗?为什么不直接转换为重量级锁呢?嗯,有可能是变成轻量级锁之后还会做个什么事,等一下看看这个偏向锁有没有释放,要不然也不需要变成轻量级再变成重量级了。)
偏向锁不总是对程序有利的,如果程序中的大多数锁总是被多个不同的线程访问,那么偏向模式就是多余的,有时候使用参数(-XX:-UseBiasedLocking)来禁止偏向锁优化反而可以提升性能。

被折叠的 条评论
为什么被折叠?



