Synchronized 精讲
1.简介
1.1 作用
在并发场景中,保证同一时刻只有一个线程对有并发隐患的代码进行操作
1.2 错误案例
需求:两个线程对 count 变量进行 200000 次循环增加,预期结果是 400000 次
结果 :显然不等于 400000 次所以出现了运算错误
原因:
该语句包含三个操作:
线程 t1、t2 从主内存中获取共享变量 count 的值,到自己的工作内存中
将自己的工作内存中的 count 值进行+1 操作
将修改完的 count 变量的值存入到主内存中
注意:他们是将自己工作内存中的值进行改变刷回主内存,假设当前 count 的值为 8,t1、t2 将 count 的值复制到自己的工作内存中进行修改,如果此时 t1 将 count 变成 9、t2 此时也将 count 的值变成 9,当 t1、t2 两个线程都将值刷回主内存的时候 count 值为 9,并不是 10,这个时候就会造成最后的结果和预期的不一致。
1.3 正确案例
代码块上加对象锁 this
在普通方法上加锁
加.class 锁
输出结果:
后文详细讲解四种加 synchronized 的方式
2.用法
2.1 对象锁
2.1.1 方法锁
修饰普通方法默认锁对象为 this 当前实例对象
public synchronized void method() ;在普通方法上面加 synchronized
输出结果: 线程 t1 和线程 t2 执行过程是顺序执行的
2.1.2 同步代码块
代码示例:没有加锁而定义的两个线程执行的情况
输出结果:线程 t1 和线程 t2 交叉执行形成了乱序
代码示例:加 Synchronized 锁而定义的两个线程执行的情况,锁对象的是 this(当前对象)
输出结果:线程 t1 和线程 t2 执行过程是顺序执行的
代码示例:加 Synchronized 锁而定义的两个线程执行的情况,锁对象的是自定义对象
输出结果:线程 t1 和线程 t2 执行形成了顺序,这种情况下和 this 没有什么区别,但是如果是多个同步代码块的话就需要进行自定义对象锁了
代码示例:多个同步代码块使用自定义对象锁,(两个自定义对象锁对应两个同步代码块)
输出结果:输出顺序线程 t1 和线程 t2 代码进行了交叉执行,出现了乱序
代码示例:多个同步代码块使用自定义对象锁,(一个自定义对象锁对应两个同步代码块)
输出结果:线程 t1 和线程 t2 执行形成了顺序
2.2 类锁
特点:类锁只能在同一时间被一个对象拥有(无论有多少个实例想访问也是一个对象持有它)
2.2.1 synchronized 修饰静态的方法
代码示例: synchronized 加在普通方法上面
输出结果:输出顺序线程 t1 和线程 t2 代码进行了交叉执行,出现了乱序
代码示例: synchronized 加在静态方法上面
public static synchronized void method();使用方式
输出结果:线程 t1 和线程 t2 执行形成了顺序
2.2.2 指定锁对象为 Class 对象
代码示例:synchronized 加.class 锁
synchronized (SynchronizedDemo5.class)
输出结果: 线程 t1 和线程 t2 执行形成了顺序
3.性质
3.1 可重入性也叫递归锁
就是说你已经获取了一把锁,等想要再次请求的时候不需要释放这把锁和其他线程一起竞争该锁,可以直接使用该锁
好处:避免死锁
粒度:线程而非调用
3.2 案例证明可重入性
证明同一个方法是可重入
代码实例:
输出结果:
证明可重入不要求是同一个方法
代码实例:
输出结果:
证明可重入不要求是同一个类中的
代码实例:
输出结果:
3.3 不可中断
当 A 线程持有这把锁时,B 线程如果也想要 A 线程持有的锁时只能等待,A 永远不释放的话,那么 B 线程永远的等待下去。
4.底层原理实现
4.1 加锁和释放锁的原理
synchronized 加在代码块上
利用 javap -verbose 类的名字查看编译后的文件
monitorenter:每个对象都是一个监视器锁(monitor)。当 monitor 被占用时就会处于锁定状态,线程执行 monitorenter 指令时尝试获取 monitor 的所有权,过程如下:
如果 monitor 的进入数为 0,则该线程进入 monitor,然后将进入数设置为 1,该线程即为 monitor 的所有者
如果线程已经占有该 monitor,只是重新进入,则进入 monitor 的进入数加 1【可重入性质】
如果其他线程已经占用了 monitor,则该线程进入阻塞状态,直到 monitor 的进入数为 0,再重新尝试获取 monitor 的所有权
monitorexit:执行 monitorexit 的线程必须是 objectref 所对应的 monitor 的所有者。指令执行时,monitor 的进入数减 1,如果减 1 后进入数为 0,那线程退出 monitor,不再是这个 monitor 的所有者。其他被这个 monitor 阻塞的线程可以尝试去获取这个 monitor 的所有权
monitorexit 指令出现了两次,第 1 次为同步正常退出释放锁;第 2 次为发生异步退出释放锁
synchronized 加在方法上(无论时普通方法还是静态方法)
利用 javap -verbose 类的名字查看编译后的文件
方法的同步并没有通过指令 monitorenter 和 monitorexit 来完成,不过相对于普通方法,其常量池中多了 ACC_SYNCHRONIZED 标示符。JVM 就是根据该标示符来实现方法的同步的:
当方法调用时,调用指令将会检查方法的 ACC_SYNCHRONIZED 访问标志是否被设置,如果设置了,执行线程将先获取 monitor,获取成功之后才能执行方法体,方法执行完后再释放 monitor。在方法执行期间,其他任何线程都无法再获得同一个 monitor 对象,其实底层还是 monitor 对象锁。
5.Java 虚拟机对 synchronized 的优化
从 JDK6 开始,就对 synchronized 的实现机制进行了较大调整,包括使用 JDK5 引进的 CAS 自旋之外,还增加了自适应的 CAS 自旋、锁消除、锁粗化、偏向锁、轻量级锁这些优化策略。所以 synchronized 关键字的优化使得性能极大提高,同时语义清晰、操作简单、无需手动关闭,所以推荐在允许的情况下尽量使用此关键字,同时在性能上此关键字还有优化的空间。
5.1 锁主要存在的四种状态
无锁状态、偏向锁状态、轻量级锁状态、重量级锁状态
锁的膨胀过程:
无锁状态 -> 偏向锁 -> 轻量级锁 -> 重量级锁
只能从低到高升级,不会出现锁的降级
5.2 自旋锁
所谓自旋锁,就是指当一个线程尝试获取某个锁时,如果该锁已被其他线程占用,就一直循环检测锁是否被释放,而不是进入线程挂起或睡眠状态。(减少线程切换)
使用场景: 自旋锁适用于锁保护的临界区很小的情况,临界区很小的话,锁占用的时间就很短。
缺点:虽然它可以避免线程切换带来的开销,但是它占用了 CPU 处理器的时间。如果持有锁的线程很快就释放了锁,那么自旋的效率就非常好,反之,自旋的线程就会白白消耗掉处理的资源,它不会做任何有意义的工作,所以增加了适应性自选锁
5.3 适应性自旋锁
所谓自适应就意味着自旋的次数不再是固定的,它是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。
线程如果自旋成功了,那么下次自旋的次数会更加多,因为上次成功了,那么此次自旋也很有可能会再次成功,那么它就会允许自旋等待持续的次数更多。反之,很少能够成功,那么以后自旋的次数会减少甚至省略掉自旋过程,以免浪费处理器资源。
5.4 锁消除
为了保证数据的完整性,在进行操作时需要对这部分操作进行同步控制,但是在有些情况下,JVM 检测到不可能存在共享数据竞争,这是 JVM 会对这些同步锁进行锁消除。作为写程序的人应该会知道哪里存在数据竞争,不可能随便的加锁。
5.5 锁粗化
将多个连续的加锁、解锁操作连接在一起,扩展成一个范围更大的锁。虽然我们平时倡导把加锁的片段尽量小为了增加并发效率和性能。但是如果一系列的连续加锁解锁操作,可能会导致不必要的性能损耗,所以引入锁粗化。
5.6 偏向锁
在大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得,为了让线程获得锁的代价更低,引进了偏向锁。偏向锁是在单线程执行代码块时使用的机制,如果在多线程并发的环境下(即线程 A 尚未执行完同步代码块,线程 B 发起了申请锁的申请),则一定会转化为轻量级锁或者重量级锁。
引入偏向锁主要目的是:为了在没有多线程竞争的情况下尽量减少不必要的轻量级锁执行路径。因为轻量级锁的加锁解锁操作是需要依赖多次 CAS 原子指令的,而偏向锁只需要在置换 ThreadID 的时候依赖一次 CAS 原子指令。
当一个线程访问同步块并获取锁时,会在对象头和栈帧中的锁记录里存储锁偏向的线程 ID,以后该线程进入和退出同步块时不需要花费 CAS 操作来争夺锁资源,只需要检查是否为偏向锁、锁标识为以及 ThreadID 即可,处理流程如下:
暂停拥有偏向锁的线程
判断锁对象是否还处于被锁定状态,否,则恢复到无锁状态(01),以允许其余线程竞争。是,则挂起持有锁的当前线程,并将指向当前线程的锁记录地址的指针放入对象头,升级为轻量级锁状态(00),然后恢复持有锁的当前线程,进入轻量级锁的竞争模式
偏向锁的获取和撤销流程:
5.7 轻量级锁
引入轻量级锁的主要目的是在没有多线程竞争的前提下,减少传统的重量级锁使用操作系统互斥量产生的性能消耗。当关闭偏向锁功能或者多个线程竞争偏向锁导致偏向锁升级为轻量级锁,则会尝试获取轻量级锁,其步骤如下:
在线程进入同步块时,如果同步对象锁状态为无锁状态(锁标志位为“01”状态,是否为偏向锁为“0”),虚拟机首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的 Mark Word 的复制。
拷贝对象头中的 Mark Word 复制到锁记录(Lock Record)中。
拷贝成功后,虚拟机将使用 CAS 操作尝试将对象 Mark Word 中的 Lock Word 更新为指向当前线程 Lock Record 的指针,并将 Lock record 里的 owner 指针指向 object mark word。如果更新成功,则执行步骤(4),否则执行步骤(5)。
如果这个更新动作成功了,那么当前线程就拥有了该对象的锁,并且对象 Mark Word 的锁标志位设置为“00”,即表示此对象处于轻量级锁定状态。
如果这个更新操作失败了,虚拟机首先会检查对象 Mark Word 中的 Lock Word 是否指向当前线程的栈帧,如果是,就说明当前线程已经拥有了这个对象的锁,那就可以直接进入同步块继续执行。否则说明多个线程竞争锁,进入自旋执行(3),若自旋结束时仍未获得锁,轻量级锁就要膨胀为重量级锁,锁标志的状态值变为“10”,Mark Word 中存储的就是指向重量级锁(互斥量)的指针,当前线程以及后面等待锁的线程也要进入阻塞状态。
轻量级锁的释放也是通过 CAS 操作来进行的,主要步骤如下:
通过 CAS 操作尝试把线程中复制的 Displaced Mark Word 对象替换当前的 Mark Word
如果替换成功,整个同步过程就完成了,恢复到无锁状态(01)
如果替换失败,说明有其他线程尝试过获取该锁(此时锁已膨胀),那就要在释放锁的同时,唤醒被挂起的线程
问题:
为什么升级为轻量锁时要把对象头里的 Mark Word 复制到线程栈的锁记录中呢?
因为在申请对象锁时需要以该值作为 CAS 的比较条件,同时在升级到重量级锁时,能通过这个比较判定是否在持有锁的过程中此锁被其他线程申请过,如果被其他线程申请了,则在释放锁的时候要唤醒被挂起的线程。
为什么会尝试 CAS 不成功以及什么情况下会不成功?
CAS 本身是不带锁机制的,其是通过比较来操作得。假设如下场景:线程 A 和线程 B 都在对象头里的锁标识为无锁状态进入,那么如线程 A 先更新对象头为其锁记录指针成功之后,线程 B 再用 CAS 去更新,就会发现此时的对象头已经不是其操作前的对象了,所以 CAS 会失败。也就是说,只有两个线程并发申请锁的时候会发生 CAS 失败。
此时线程 B 进行 CAS 自旋,等待对象头的锁标识重新变回无锁状态或对象头内容等于对象,这也就意味着线程 A 执行结束,此时线程 B 的 CAS 操作终于成功了,于是线程 B 获得了锁以及执行同步代码的权限。如果线程 A 的执行时间较长,线程 B 经过若干次 CAS 时钟没有成功,则锁膨胀为重量级锁,即线程 B 被挂起阻塞、等待重新调度。
5.8 重量级锁
Synchronized 是通过对象内部的一个叫做监视器锁(Monitor)来实现的。但是监视器锁本质又是依赖于底层的操作系统的 Mutex Lock 来实现的。而操作系统实现线程之间的切换这就需要从用户态转换到核心态,这个成本非常高,性能消耗特别严重。因此,这种依赖于操作系统 Mutex Lock 所实现的锁我们称之为 “重量级锁”。
6. 缺点
效率低
锁的释放情况少
试图获取锁时不能设定超时
不能中断一个正在试图获得锁的线程
不够灵活
加锁和释放锁的时候单一,每个锁仅有一个单一条件
不知道是否成功获取锁
版权声明: 本文为 InfoQ 作者【伯阳】的原创文章。
原文链接:【http://xie.infoq.cn/article/a65509912632af21a8d2cdda7】。文章转载请联系作者。
评论 (3 条评论)