juc之synchronized、Lock和volatile
问题
- 为什么 synchronized 编译后 存在一个 monitorenter 和两个 monitorExit?
- 什么是 happens-before ?
引言,描述 为什么会有并发问题?
可见性、有序性、原子性?
本篇将讲述 java 中的并发关键字: synchronized 、 volidate 、 final
在构造器上添加 synchronized 无意义且会报错,因为线程调用构造器创建对象需要权限。
synchronized
synchronized 如何保障线程安全?
1 | package com.example.securityPlus.juc; |
执行 javac 编译得到 class 文件。再使用 javap -c xx.class 得到反编译后的内容:
1 | public class com.example.securityPlus.juc.SynchronizedDemo2 { |
注意到 method1 中 6、8、14 行有特殊标记: monitorenter 和 monitorexit
,分别标识
monitorenter
: 每个对象都有与之关联的 monitor。 执行 monitorenter 的线程将获得与 objectref关联的 monitor的所有权。如果其他线程已经获得了该monitor的objectref,则当前线程需等待此对象释放锁,然后尝试获取此对象的所有权。如果当前线程已经获得了objectref关联的对象的 monitor, 它将增加 monitor内部的counter值以标识当前线程多次获取了这个monitor。如果没有其他线程获取该objectref关联的 monitor,则当前线程将 此 monitor的 count 值置为 1.monitorexit
: 执行此方法的线程必须获取当前objectref关联的monitor。使用此命令以减少标识线程重入次数的counter。如果计数值为0,则标识当前线程释放monitor。如果该 monitor已释放,且有其他线程等待获取,则其他线程将获取该monitor。
内部锁和同步
同步建立在 内部锁或者监视锁的实体上。内部锁在如下两个同步观念上扮演重要角色:
- 强制排它锁
- 建立 happens-before 联系以保证结果的可见性
如果线程调用一个 同步方法, 它将获取该 method 对象的 内部锁,并在方法返回时释放其内部锁(方法异常时也会释放锁)。
当调用静态的同步方法时,该线程将获取该Class的内部锁,因为静态方法属于类而非实例。即对静态属性的访问由类Class锁控制。
synchronized 的使用场景
作用于实例方法
1 | public class SynchronizedCounter { |
对于 SynchronizedCounter 实例而言,将方法设置为 synchronized 有如下两个好处:
- 不可能存在两个线程同时调用同步方法。 如果已有一个线程执行A对象的同步方法,其他调用A对象的同步方法的线程将被阻塞直到第一个线程处理完毕。
- 当同步方法退出时,会自动与同一对象的同步方法的任何后续调用建立
happens-before
关系。以此保证对象状态的修改对所有线程可见。
作用于静态方法
当调用静态的同步方法时,该线程将获取该Class的内部锁,因为静态方法属于类而非实例。即对静态属性的访问由类Class锁控制。
这就导致当多个线程尝试调用该同步方法时,需要获取该静态类对象所持有的锁,会有并发问题。
happens-before
用以表述两个事件结果之间的关系, 即一个事件应该在另外一个事件前发生,结果必须保证这一点,即使某些事件实际上是无序执行的。(用以优化程序流)
作用于代码块
声明同步代码块是必须指定对象以获取内部锁。在使用时有程序员自行指定锁,可以是实例对象,也可以是类对象。
1 | public class SynchronizedDemo4 { |
如上的方法中, synchronized 只需要覆盖 lastName = name;
和 nameCount++;
, 而非整个方法块。
另外,使用同步方法还可以提升并发。
1 | public class MsLunch { |
如上同步方法分别获取类属性中的两个对象的锁可以提升这两个方法的并发速度。
可重入锁 Reentrant Synchronization
允许一个拥有锁的线程重复获取该锁称之为 可重入同步。
它描述这样的场景 当 同步代码块直接或间接调用一个包含同步代码块的方法,且两块代码使用的锁相同。如果没有可重入锁,同步代码块将不得不采取额外预防措施,以避免线程导致自身阻塞。
synchronized 有哪些缺陷?
- 效率低: 锁释放情况少,只有代码执行完毕或者异常结束时才会释放锁。尝试获取锁时不能设定超时,不能中断一个正在使用锁的线程。
- 不够灵活:加锁和释放锁时机单一,每个锁仅有单一条件(对象),相对而言,读写锁更加灵活
- 无法知道是否成功获得锁,而lock可以拿到状态
锁优化
前面反编译得到的字节码中 有 monitorenter 和 monitorexit, 其实现依赖底层操作系统的 mutex lock。由于 mutex lock 需要将当前线程挂起并从用户态切换到内核态,这种切换的代价相当高昂。
java6之后对锁的实现进行了优化: 如锁粗化、锁消除、轻量级锁、偏向锁、适应性自旋
等技术来减少锁操作的开销。
- 锁粗化: 减少不必要的紧连在一起的 unlock、lock操作,将多个连续的锁扩大为更大范围的锁。
- 锁消除: 通过JIT的逃逸分析来消除一些 除了在当前同步块以外没有被其他线程共享的锁。
- 轻量级锁:基于如下假设, 大部分同步代码一般处于无所竞争状态(单线程执行环境),在此情况下完全可以避免调用操作系统层面的重量级互斥锁,只需要依靠一条cas原子指令就可以完成锁的获取和释放。当存在锁竞争时,执行cas指令失败的线程将调用操作系统互斥锁进入阻塞状态,当锁被释放时被唤醒。
- 偏向锁:为了在无锁竞争场景下避免在锁获取过程中执行非必要的 CAS原子指令。
- 适应自旋: 当线程在获取轻量级锁的过程中执行CAS操作失败时,在进入与 monitor 相关联的操作系统重量级锁时会进入 忙等待 (spinning) 然后再次尝试, 尝试一定次数后如果仍然没有成功,则调用与该 monitor 关联的 互斥锁 semaphore 进入到阻塞状态。
Lock使用
1.
1 | Lock l = ...; |
lock 和 unlock 可以在不同的语句块中,但是使用 lock 时 获取锁所执行的代码 必须在 try-finally 或者 try-catch 语句块中,以保障 lock 被释放
除了 synchronized 所提供的功能外,lock 还提供了 非阻塞的方式获取锁 tryLock()
、 可中断锁 lockInterruptibly()
、超时锁 tryLock(long, TimeUnit)
Lock 类还能提供与隐式监视所完全不同的行为和语义。例如保证排序、不可重入使用或死锁检测。
内存同步
- 成功的 lock 操作 需要保障内存同步
- 成功的 unlock 需要保障内存同步
- 不成功的 lock、 unlock、重入 lock、 unlock 不需要保障内存同步
lock 的优势?
- 获取锁更灵活:synchronized 获取锁不够灵活,而lock 则提供多种支持
- 效率更高: 多线程竞争锁时,未获得锁的线程只能重试获取不能中断。高并发场景下性能下降。 reentrantLock 的
lockInterruptibly()
可以优先考虑响应中断。线程等待时间过长,可以中断自己,让 reentrantLock 响应此中断,不再让此线程继续等待。同时可以设置超时
synchronized 和 lock 对比以及如何选择?
实现层次
- synchronized: java关键字,作用于jvm
- lock: 接口
锁释放
- synchronized:
- 已获取锁的线程执行同步代码后,释放锁
- 线程执行发生异常, jvm 会让线程释放锁
- lock: 在 finally 中必须释放锁, 否则容易导致线程死锁
- synchronized:
锁获取
- synchronized: 线程A获取锁后其余线程等待。
- Lock:Lock 有多个锁的获取方式,会尝试获取锁,线程可以不用一直等待, 可以使用 tryLock 判断是否有锁
锁状态
- synchronized: 无法判断
- Lock: 可以判断
锁类型
- synchronized: 可重入,不可中断、非公平锁
- Lock: 可重入、可判断、可公平
性能
- synchronized: 少量同步
- Lock: 可大量同步
- Lock可以提高多个线程进行读操作的效率。(可以通过readwritelock实现读写分离) 在资源竞争不是很激烈的情况下,Synchronized的性能要优于ReetrantLock,但是在资源竞争很激烈的情况下,Synchronized的性能会下降几十倍,但是ReetrantLock的性能能维持常态;ReentrantLock提供了多样化的同步,比如有时间限制的同步,可以被Interrupt的同步(synchronized的同步是不能Interrupt的)等。
- 在资源竞争不激烈的情形下,性能稍微比synchronized差点点。但是当同步非常激烈的时候,synchronized的性能一下子能下降好几十倍。而ReentrantLock确还能维持常态。
调度
- synchronized: 使用 Object 对象本身的 wait 、notify、notifyAll 调度机制
- Lock: 使用Condition进行线程调度
使用:
- synchronized:在需要同步的对象中加入,可以加在方法上也可以加在特定代码块中,括号标识锁对象
- Lock: 一般使用rerentrantLock 作为锁,加锁和解锁显示使用 lock() 和 unlock()。 通常需要在 finally 语句块中 unlock()
底层实现
- synchronized:使用指令码的方式控制锁,映射成字节码即 monitorenter 和 monitorexit。 线程遇到 monitorenter 时 尝试获取内置锁, 成功获取则 锁计数 +1;否则阻塞。 遇到monotorexit时, 锁计数器-1, 计数器=0 则释放锁。
- 底层是CAS乐观锁,依赖AbstractQueuedSynchronizer类,将所有请求线程构造成CLH队列,,对该队列的操作均通过CAS操作。
从 DCL 单例 分析 synchronized 和 volatile 的差异?
如下是 单例模式的 DCL 双重检测锁的实现, 关于单例模式的更多实现,参见 设计模式-单例模式
1 | public class Singleton { |
上述代码中, 使用关键字 volatile
修饰静态变量 singleton。原因在于,该对象在创建时可能发生指令重排序。
对象实例化过程包含如下三步:
- 分配内存空间
- 初始化对象
- 将内存空间的地址赋值给对应的引用
但是由于操作系统对指令进行重排序,所以上述过程可能会变成:
- 分配内存空间
- 将内存空间的地址赋值给对应的引用
- 初始化对象
可以看到,多线程环境下可能将一个未初始化的对象引用暴露出来,为了防止此类现象的发生,需要对静态变量使用 volatile
标注。
volatile 关键字的 特点??
- 可见性: 对一个 volatile 变量的读,总是能看到 任意线程对 该 volatile变量最后的写入
- 原子性:对任意单个volatile变量的读写具有原子性,但类似 volatile++ 这种复合操作不具备原子性。
- 有序性:对 volatile 修饰的变量读写前后加上各种特定的内存屏障来禁止指令重排序以保障有序性。
volatile 读-写语义:
写 volatile 对象时, JMM会将该线程对应的本地内充中的共享变量之刷新到主内存。
读 volatile 对象时, JMM会把该线程对应的本地内存设置为无效,线程从主内存中读取共享变量。
volatile 关键字的作用??
volatile 是在声明任何引用设备寄存器的变量时必须使用的关键字。它指示编译器对于声明的对象使用精确的语义,而不是优化或者对对象的访问重新排序。
如下两种场景必须使用 volatile
关键字
- 当数据指向外部硬件设备寄存器时(如果使用DDI数据访问函数来访问设备寄存器,则不需要)
- 当数据引用 可以由多个线程访问、不受锁保护、依赖内存访问顺序的 全局内存时, 使用 volatile 比 lock 便宜。
voatile 是如何保障可见性的?
缓存一致性协议 MESI
- M: 修改 modify, 当一个线程要修改
- E: 独享、互斥 exclusive, 当一个线程拿到了共享变量,此时为独享状态
- S:共享 shared, 多个线程拿到共享变量,此时为共享状态
- I:无效 invalid, 线程丢弃了自己工作内存中的变量,此时为无效状态
MESI 如何保障可见性?
- cpu 根据共享变量是否带有 volatile 关键字,来决定是否使用 MESI 协议保障缓存一致性
- 存在 volatile, 汇编层面对变量加上 LOCK 前缀,当一个线程修改变量的值后,会马上经过 store、write 等原子操作修改主内存中的值。以此触发 cpu 的嗅探机制,及时失效其他线程变量副本。
- cpu的总线嗅探机制监听到这个值被修改,就会把其他县城的变量副本由 共享S 设置为 无效I, 当其他线程在使用变量副本时,发现已经无效则会去主内存中拿最新值。
在写入主内存时为什么要加锁?加在哪里?
变量被修改后同步到主内存的过程中会在 store
之前加锁,写完之后解锁,只有在修改时才会加此锁,锁粒度非常小。
在store时可能已经过了总线,但是此时还没有 write 入 主内存,总线却触发了嗅探机制,其他线程变量已经失效,当其他县城去主内存读取最新数据时u,新数据还未写入,产生脏数据。
voatile 是如何保障有序性的?
通过对 volatile 修饰的 变量增加内存屏障。
内存屏障的工作原理为: 通过在指令检查如一条内存屏障并禁止cpu对volatile修饰的变量进行重排序,也即通过插入内存屏障防止在内存屏障前后执行重排序优化
JMM内存屏障插入策略:
- 在每一个 volatile 写操作前,插入 StoreStore 屏障
- 在每一个 volatile 写操作后,插入 StoreLoad 屏障
- 在每一个 volatile 读操作前,插入 LoadLoad 屏障
- 在每一个 volatile 写操作后, 插入 LoadStore 屏障
JMM 规定, 所有变量都存储在主内存中,主内存时共享内存区域,所有线程均可访问。当线程对此变量有操作时,必须将这个变量从主内存复制一份到自己的内存空间进行操作,操作完成后,再把变量写回主内存,不能直接操作主内存的变量。
总结
synchronized 关键字的作用:
- 通过使用互斥锁来锁定共享资源,使得同一时刻只有一个线程可以访问和修改,其他线程必须等待。
- synchronized 的有序性是指持有两个相同锁的同步代码块只能串行进入。但是代码块中的内容还是会发生重排序。
而 volatile 的有序性是通过插入内存屏障来保障指令按照顺序执行,不存在后面的指令跑到前面的指令之前执行,以保证编译器优化时,不会让指令乱序。
引用
1. Synchronized Methods
2. monitorenter
3. happens-before
4. Lock
5. 并发关键字
6. 设计模式-单例模式
7. volatile
8. volatile是怎么保证可见性和有序性的,为什么无法保证原子性