synchronized 只会用不知道原理?一文搞定
听说微信搜索《Java 鱼仔》会变更强哦!
本文收录于JavaStarter,里面有我完整的 Java 系列文章,学习或面试都可以看看哦
(一)概述
在多线程的程序执行中,有可能会出现多个线程会同时访问一个共享并且可变资源的情况,这种时候由于线程的执行是不可控的,所以必须采用一些方式来控制该资源的访问,这种方式就是“加锁”。
我们把那些可能会被多个线程同时操作的资源称为临界资源,加锁的目的就是让这些临界资源在同一时刻只能有一个线程可以访问。
(二)CAS 的介绍
CAS:compare and swap,比较且交换。使用 CAS 操作可以在没有锁的情况下完成多线程对一个值的更新。CAS 的具体操作如下:
当要更新一个值时,先获取当前值 E,计算更新后的结果值 V(先不更新),当要去更新这个值时,比较此时这个值是否还是等于 E,如果相等,则将 E 更新为 V,如果不相等,则重新进行上面的操作。
以 i++操作为例,在没有锁的情况下,这个操作是线程不安全的,假设 i 的初始值为 0,CAS 操作先获取原值 E=0,计算更新后的值 V=1,要更新之前先比较这个值是否还是等于 0,如果等于 0 则将 E 更新为 1,如果不等于 0 则说明有线程已经更新了,重新获取 E 值=1,继续执行。
ABA 问题
CAS 操作可能会出现 ABA 问题,ABA 问题即我们要去比较的这个值 E,经过多个线程的操作后从 0 变成 1 又变成了 0。此时虽然 E 值和更新前相等,但是还是已经被更新了。
ABA 问题的解决办法
对 E 值增加一个版本号,每次要获取数据时将版本号也获取,每次更新完数据之后将版本号递增,这样就算值相等通过版本号也能知道是否经过修改。
java 在很多地方都用到了 CAS 操作,比如 Atomic 的一些类:
进入 AtomicInteger 方法中,可以看到有个叫 Unsafe 的类,进入这个类中,可以看到 CAS 的几个操作方法
(三)对象在内存中的存储布局
要想学会 synchronized,首先要理解 Java 对象的内存布局,或者称为内存结构。
一个对象分为对象头、实例数据和对其填充。
其中对象头 Header 占 12 个字节:Mark Word 占 8 个字节,类型指针 class pointer 占 4 个字节(默认经过了压缩,如果不开启压缩占 8 个字节)
实例对象按实际存储有不同大小,对象为空时等于 0。
Padding 表示对齐,当此时内存所占字节不能被 8 整除时补上相应字节数。
以 Object o=new Object()为例,我们先导入一个 jol 依赖,通过 jol 可以看到具体的内存布局
运行以下代码:
观察结果,OFFSET 表示偏移量的起始点,SIZE 表示所占字节,前两行是 Mark Word 一共占 8 个字节,第三行是 class pointer 占 4 个字节,此时对象为空,实例对象等于 0,最后 padding 补齐,一共 16 个字节。
(三)synchronized
synchronized 可以保证在同一时刻,只有一个线程可以执行某个方法或某个代码块,synchronized 把锁信息存放在对象头的 MarkWord 中。
synchronized 作用在非静态方法上是对方法的加锁,synchronized 作用在静态方法上是对当前的类加锁。
在早期的 jdk 版本中,synchronized 是一个重量级锁,保证线程的安全但是效率很低。后来对 synchronized 进行了优化,有了一个锁升级的过程:
无锁态(new)-->偏向锁-->轻量级锁(自旋锁)-->重量级锁
通过 MarkWord 中的 8 个字节也就是 64 位来记录锁信息。也有人将自旋锁称为无锁,因为自选操作并没有给一个对象上锁,这里只要理解意思即可。
3.1 锁升级过程详解:
当给一个对象增加 synchronized 锁之后,相当于上了一个偏向锁。
当有一个线程去请求时,就把这个对象 MarkWord 的 ID 改为当前线程指针 ID(JavaThread),只允许这一个线程去请求对象。
当有其他线程也去请求时,就把锁升级为轻量级锁。每个线程在自己的线程栈中生成 LockRecord,用 CAS 自旋操作将请求对象 MarkWordID 改为自己的 LockRecord,成功的线程请求到了该对象,未成功的对象继续自旋。
如果竞争加剧,当有线程自旋超过一定次数时(在 JDK1.6 之后,这个自旋次数由 JVM 自己控制),就将轻量级锁升级为重量级锁,线程挂起,进入等待队列,等待操作系统的调度。
3.2 加锁的字节码实现
synchronized 关键字被编译成字节码之后会被翻译成 monitorenter 和 monitorexit 两条指令,进入同步代码块时执行 monitorenter,同步代码块执行完毕后执行 monitorexit
(四)锁消除
在某些情况下,如果 JVM 认为不需要锁,会自动消除锁,比如下面这段代码:
StringBuffer 是线程安全的,但是在这个 add 方法中 stringbuffer 是不能共享的资源,因此加锁只会徒增性能消耗,JVM 就会消除 StringBuffer 内部的锁。
(五)锁粗化
在某些情况下,JVM 检测到一连串的操作都在对同一个对象不断加锁,就会将这个锁加到这一连串操作的外部,比如:
上述操作 StringBuffer 每次添加数据都要加锁和解锁,连续 100 次,这时候 JVM 就会将锁加到更外层(while)部分。
(六)逃逸分析
首先问一个经常基础的虚拟机问题,实例对象存放在虚拟机的哪个位置?按以前的回答,示例对象放在堆上,引用放在栈上,示例的元数据等存放在方法区或者元空间。
但这是有前提的,前提是示例对象没有线程逃逸行为。
JDK1.7 开始默认开启了逃逸分析,所谓逃逸分析,就是指如果一个对象被编译器发现只能被一个线程访问,那么这个对象就不需要考虑同步。JVM 就对这种对象进行优化,将堆分配转化为栈分配,归根结底就是虚拟机在编译过程中对程序的一种优化行为。
版权声明: 本文为 InfoQ 作者【Java鱼仔】的原创文章。
原文链接:【http://xie.infoq.cn/article/6a72c467d58890d4e1341b60b】。文章转载请联系作者。
评论 (1 条评论)