线程安全与锁优化
线程安全:当多个线程同时访问一个对象时,如果不用考虑这些线程在运行时环境下的调度和交替执行,也不需要进行额外的同步操作,或者在调用方进行任何其他的协调操作,调用这个对象的行为都可以获得正确的结果,那么就称这个对象是线程安全的。
线程安全分类方法
线程安全性的分类方法,按照由强至弱进行排序,可以分为:
-
不可变:不可变对象一定是线程安全的,无论是对象的方法实现还是方法的调用者都不需要再进行任何线程安全的保障措施。
-
对于一个基本类型的数据只要定义时使用final关键字修饰即可保证不可变。
-
对于一个对象来说需要自行保证其行为不会对其状态产生任何影响,对象的方法不会影响其原来的值,只会返回一个新的对象,如String。
-
-
绝对线程安全:不管运行时环境如何,调用者都不需要任何额外的同步措施。
-
相对线程安全:需要保证对这个对象单次的操作是线程安全,调用的时候不需要进行额外的保障措施。
-
线程兼容:对象本身并不是线程安全的,可以通过在调用端正确地使用同步手段来保障对象在并发环境中可以安全使用。
-
线程对立:不管在调用端是否采取了同步措施,都无法在多线程中并发地使用代码。
线程安全实现方法
-
互斥同步:
-
作用:指在多个线程并发访问共享数据时,保证共享数据在同一个时刻只被一条(或一些)线程使用。互斥是实现同步地一种手段。
-
方法:临界区、互斥量及信号量。
-
实现:
-
通过synchronized关键字实现同步的话:
-
关键字在同步块的前后分别会形成monitorenter和monitorexit两个字节码指令。这两个字节码指令需要指定reference参数来指明要锁定和解锁的对象。
-
当执行monitorenter指令时,线程尝试获取锁,如果对象没被锁定或者当前线程已经持有了该对象的锁,那么就把锁的计数器的值增加一。
-
执行monitorexit时会将计数器的值减一,当计数值为0时,锁被释放。
-
-
如果未获取到锁,则阻塞等待,直到对象被释放为止。
-
对于synchronized修饰的同步块对同一条线程来说是可重入的,且锁的过程无法中断。
-
-
基于Java类库中的Lock接口实现同步,其特点就在于可以以非块的结构实现互斥同步,摆脱了语言特性的束缚。
-
可重入锁(ReentrantLock):相较于synchronized,存在等待可中断、公平锁、锁绑定多个条件三个优点。
-
等待可中断:持有锁的线程长期不释放锁时,等待线程可以放弃等待,改为处理其他事情。
-
公平锁:公平锁必须按照申请锁的顺序来依次获取锁,非公平锁不保证这一点,任何等待锁的线程均有机会获取锁。性能相较于非公平锁会提升很多,有几率减少一次线程的挂起和唤醒的开销。
-
锁绑定多个条件:指一个ReentrantLock对象可以绑定多个Condition对象。
-
-
-
-
Synchronized在JDK 6进行了大量的优化,性能上已与ReentrantLock不相上下。如果Synchronized及ReentrantLock能够同时满足代码同步要求,应该优先选择Synchronized,理由在于:
-
Synchronized是在Java语法层面的同步,更加清晰简单。
-
Lock需要确保在finally块中释放锁,需要程序员控制,否则可能永远不释放锁。
-
Java虚拟机更容易针对Synchronized进行优化,因为虚拟机可以看到在线程和元数据中记录的锁信息。
-
-
互斥同步也称为阻塞同步,主要存在的问题是线程阻塞和唤醒带来的性能开销。它基于一种悲观的并发策略,认为只要不做正确的同步措施,就一定会出现问题,因此不管数据是否出现竞争,它都会进行加锁。
-
-
非阻塞同步:基于冲突检测的乐观并发策略,不管风险直接操作数据。如果没有其他线程争用共享数据,操作便会直接成功。如果数据真的被争用,产生了冲突,再进行其他补偿措施,如重试等。这种方式不会阻塞线程,因此被称为非阻塞同步。
-
处理器指令:
-
测试并设置(Test-and-Set)
-
获取并增加(Fetch-and-Increment)
-
交换(Swap)
-
比较并交换(Compare-and-Swap)
-
加载链接/条件储存(Load-Linked/Store-Condition)
-
-
Java最终暴露出的操作为CAS操作,即当旧值与预期相等时,将值设置为新值。这个操作仅要求值的设置操作时原子的。但无法解决ABA问题。
-
-
无同步方案 :天生线程安全的代码,不需要任何同步措施保护其正确性。
-
可重入代码(RetrantLock):即只要输入了相同的数据,一定返回相同结果的代码。
-
线程本地存储(Thread Local Storage):共享数据可见范围限制在一个线程内,无须同步也能保证线程间无数据争用问题。如消息队列。
-
被volatile关键字修饰的变量。
-
锁优化
-
自旋锁与自适应自旋
-
自旋锁:
-
优点:由于共享数据的锁定状态可能只会持续很少的时间,为了这段时间去挂起和恢复线程并不值得。因此让线程执行一个忙循环,使线程等待一会。
-
缺点:自旋等待不能替代阻塞,因为自旋需要占用处理器时间,如果自旋等待时间过长,就会白白消耗大量处理器资源。
-
解决方案:限定自旋次数,超过次数就会挂起线程
-
-
自适应自旋:自旋如果前一次很快获得锁,则认为这次也能自旋获得锁,延长自旋等待次数。反之,则减少甚至省略自旋。
-
-
锁消除:对不可能存在共享数据竞争的锁进行消除。
-
基于逃逸分析,如果一段代码中,在堆上的所有数据都不会逃逸出去被其他线程访问到,就可以将其当作栈上数据对待,认为其时线程私有的。
-
-
锁粗化:
-
问题:同步块的作用范围应该限制的尽量小,以保证线程能够尽快拿到锁。但如果一段代码中的一系列操作都对同一个对象反复加锁和解锁,过于频繁的操作也会带来很大的形成损耗。
-
解决:此时需要将锁加到整个序列的外部,这个过程就叫锁粗化。
-
-
轻量级锁
-
加锁过程:
-
首先如果同步对象未被锁定,对象的锁标注位为“01”,虚拟机会在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的Mark Word拷贝。
-
虚拟机使用CAS操作尝试将对象的Mark Word更新为指向Lock Record的指针,更新成功则意味着获得了锁,对象Mark Word的锁标注位转变为“00”,表示对象处于轻量级锁定状态。
-
如果更新操作失败,那么意味着至少存在一条线程与当前线程竞争取该对象的锁。
-
虚拟机会检查对象的Mark Word是否指向当前线程的栈帧,如果是则继续执行,否则将锁膨胀为重量级锁,锁标志的状态修改为“10”,后续线程也必须进入阻塞状态。
-
-
解锁过程:
-
通过CAS操作,尝试把对象当前的Mark Word和线程中复制的Displaced Mark Word替换回来,如果能成功替换,则说明同步过程完成,如果替换失败,则说明有其他线程尝试获取该锁,需要在释放的同时唤醒线程。
-
-
优点:在无竞争的情况下,轻量级锁通过CAS机制避免了使用互斥量的开销。
-
缺点:在有竞争的情况下,除了互斥量本身的开销,还额外发生了CAS操作的开销,性能反而比重量级锁更慢。
-
-
偏向锁:相较于轻量级锁在无竞争的情况下使用CAS操作消除同步使用的互斥量,偏向锁在无竞争的情况下会消除整个同步操作。
-
偏向锁会使用CAS操作将获取到锁的线程ID记录在对象的Mark Word中,如果CAS操作成功,持有偏向锁的线程以后每次进入锁相关的同步块时,虚拟机都可以不再进行任何的同步操作。
-
如果有另外的线程尝试获取锁,锁会膨胀为轻量级锁。
-
如果对象如果计算过哈希码,则会撤销偏向模式,锁会膨胀为重量级锁。
-
偏向锁同样时基于乐观机制的锁,如果锁总是被多个线程访问,偏向锁总是多余的,禁止偏向锁反而可以提升性能。
-
线程同步工具类
- CountDownLatch(闭锁):能够使一个线程在等待另外一些线程完成各自工作之后,再继续执行。
- 实现:基于计数器实现,计数器初始值为线程的数量。当每一个线程完成自己任务后,计数器的值就会减一。当计数器的值为0时,表示所有的线程都已经完成一些任务,然后在CountDownLatch上等待的线程就可以恢复执行接下来的任务。
- 应用:
- 主线程等待多个子线程组件加载完毕后再继续执行场景
- 多线程执行任务提升最大并行性
- FutureTask 用于执行一个可返回结果的长任务,任务在单独的线程中执行,其他线程可以用 get 方法取任务结果,如果任务尚未完成,线程在 get 上阻塞。
- Semaphore 用于控制同时访问某资源,或同时执行某操作的线程数目。信号量有一个初始值即可以分配的信号量总数目。线程任务开始前先调用 acquire 取得信号量,任务结束后调用 release 释放信号量。在 acquire 是如果没有可用信号量,线程将阻塞在 acquire 上,直到其他线程释放一个信号量。
- CyclicBarrier 栅栏用于多个线程多次迭代时进行同步,在一轮任务中,任何线程完成任务后都在 barrier 上等待,直到所有其他线程也完成任务,然后一起释放,同时进入下一轮迭代。