刚开始认识 volatile 的时候, 觉得对它的一些特性非常迷惑. 比如: 具有可见性, 如果一个线程修改了 volatile 变量的值, 那么其它线程也会发现这一点; 同时它又不具有原子性, 多个线程对被 volatile 修饰的 int 变量累加会造成相互覆盖. 这我就迷糊了: 不是一个线程修改了, 其它的线程中数据都无效了么, 既然会重新读取, 为啥最终还会相互覆盖呢?
volatile 原理:
我们知道: 如果一个字段被声明成 volatile,java 线程内存模型确保所有线程看到这个变量的值是一致的. 这个就是所谓的 "可见性", 就是一个线程修改了, 其他线程能知道这个操作, 这就是可见性. 如何实现的呢? volatile 修饰的变量在生成汇编代码的时候, 会产生一条 lock 指令, lock 前缀的指令在多核处理器下会引发两件事情:
1, 将当前处理器缓存航的数据写回到系统内存;
2, 这个写回内存的操作会使得在其它 CPU 里缓存了该内存地址的数据无效;
这个使得其它 CPU 里数据无效又是怎么实现的呢?
CPU 处理数据速度是很快的, 为了提高处理速度, 充分发挥 CPU 性能, CPU 不直接跟内存进行通信, 而是先将数据读入 CPU 高速缓存后再进行操作, 但操作完不知道何时回写到内存. 如果对声明了 volatile 的变量进行写操作, jvm 就会向处理器发送一条 lock 前缀指令, 将这个变量所在缓存行的数据写回到系统内存. 但就算写回到内存, 如果其它处理器缓存的还是旧值, 再执行计算操作就会有问题. 所以多处理器下, 为了保证各个处理器的缓存是一致的, 就有了一个 "缓存一致性协议", 所有硬件厂商都要按照这个标准来生产硬件. 具体就是每个处理器通过嗅探在总线上传播的数据来检查自己缓存的值是不是过期了, 当处理器发现自己缓存行对应的内存地址被修改, 就会将当前处理器的缓存行设置为无效状态, 当处理器对这个数据进行修改操作的时候, 会重新从系统内存中把数据读到处理器缓存.
注意, 如果该数据已经在别的处理器线程被修改过了, 只是没有刷新到内存, 则这时候是不会重新读数据的, 而是等一下直接刷新到内存, 这就造成了覆盖的事情发生; 别的线程重新读取数据仅仅是在将变量读到了 CPU 缓存, 还没有使用的时候才有的, 一旦使用了, 即使发现被修改了, 也不会重新读取重新计算. 具有可见性, 而又多线程不安全的问题就是这样产生的.
该部分可以结合: jvm 线程模型跟 jvm8 种内存基本操作
synchronized 原理:
synchronized 是用 java 的 monitor 机制来实现的, 就是 synchronized 代码块或者方法进入及退出的时候会生成 monitorenter 跟 monitorexit 两条命令. 线程执行到 monitorenter 时会尝试获取对象所对应的 monitor 所有权, 即尝试获取的对象的锁; monitorexit 即为释放锁.
monitor 机制是跟 java 对象结构相关的. HotSpot 虚拟机中, 对象在内存中存储的布局可以分为三块区域: 对象头, 实例数据跟对齐填充.
从上面的这张图里面可以看出, 对象在内存中的结构主要包含以下几个部分:
Mark Word(标记字段): 对象的 Mark Word 部分占 4 个字节, 其内容是一系列的标记位, 比如轻量级锁的标记位, 偏向锁标记位等等.
Klass Pointer(Class 对象指针):Class 对象指针的大小也是 4 个字节, 其指向的位置是对象对应的 Class 对象 (其对应的元数据对象) 的内存地址
对象实际数据: 这里面包括了对象的所有成员变量, 其大小由各个成员变量的大小决定, 比如: byte 和 boolean 是 1 个字节, short 和 char 是 2 个字节, int 和 float 是 4 个字节, long 和 double 是 8 个字节, reference 是 4 个字节
对齐: 最后一部分是对齐填充的字节, 按 8 个字节填充.
其实, 如果是数组对象, 头信息还包括一个 Array length 的内容, 用来记录数组长度.
我们看这个 Mark Word, 它包含对象的 hashcode, 分代年龄跟锁标记位 3 部分. 具体结构如下(32 位虚拟机):
64 位虚拟机下, Mark Word 是 64bit 的, 存出结果如下:
这么复杂的结构, 跟 synchronized 有什么关系呢?
当然有关系, synchronized 就是利用以上结构来实现的, 每次就是抢占上边的 Mark Word, 然后修改里边各个小段的内容; 然后, jdk 的开发人员经过研究发现, 大多数情况下, 锁不仅不存在多线程竞争, 而且总是由同一线程多次获得, 那我们老是抢来抢去的岂不是没意义. 于是考虑进行优化, 也就有了偏向锁, 轻量级锁以及重量级锁的概念. 然后接下来我们来看这三种锁究竟是怎么一回事儿.
偏向锁:
简单的讲, 就是在锁对象的对象头中有个 ThreaddId 字段, 这个字段如果是空的,
第一次获取锁的时候, 就将自身的 ThreadId 写入到锁的 ThreadId 字段内, 将锁头内的是否偏向锁的状态位置 1.
这样下次获取锁的时候, 直接检查 ThreadId 是否和自身线程 Id 一致, 如果一致, 则认为当前线程已经获取了锁, 因此不需再次获取锁, 略过了轻量级锁和重量级锁的加锁阶段. 提高了效率.
但是偏向锁也有一个问题, 就是当锁有竞争关系的时候, 需要解除偏向锁, 使锁进入竞争的状态.
上图中只讲了偏向锁的释放, 其实还涉及偏向锁的抢占, 其实就是两个进程对锁的抢占, 在 synchrnized 锁下表现为轻量锁方式进行抢占.
注: 也就是说一旦偏向锁冲突, 双方都会升级为轻量级锁.(这一点与轻量级 ->重量级锁不同, 那时候失败一方直接升级, 成功一方在释放时候 notify)
轻量级锁:
之后会进入到轻量级锁阶段, 两个线程进入锁竞争状态(注, 我理解仍然会遵守先来后到原则; 注 2, 的确是的, 下图中提到了 mark Word 中的 lock record 指向堆栈中最近的一个线程的 lock record), 一个具体例子可以参考 synchronized 锁机制.
每一个线程在准备获取共享资源时:
第一步, 检查 MarkWord 里面是不是放的自己的 ThreadId , 如果是, 表示当前线程是处于 "偏向锁" ;
第二步, 如果 MarkWord 不是自己的 ThreadId, 锁升级, 这时候, 用 CAS 来执行切换, 新的线程根据 MarkWord 里面现有的 ThreadId, 通知之前线程暂停, 之前线程将 Markword 的内容置为空.
第三步, 两个线程都把对象的 HashCode 复制到自己新建的用于存储锁的记录空间, 接着开始通过 CAS 操作, 把共享对象的 MarKword 的内容修改为自己新建的记录空间的地址的方式竞争 MarkWord;
第四步, 第三步中成功执行 CAS 的获得资源, 失败的则进入自旋 第五步, 自旋的线程在自旋过程中, 成功获得资源(即之前获的资源的线程执行完成并释放了共享资源), 则整个状态依然处于 轻量级锁的状态, 如果自旋失败 第六步, 进入重量级锁的状态, 这个时候, 自旋的线程进行阻塞, 等待之前线程执行完成并唤醒自己;
重量级锁:
这个就是我们平常说的 synchronized 锁, 如果抢占不到, 则线程阻塞, 等待正在执行的线程结束后唤醒自己, 然后重新开始竞争. 之所以说是重量级, 是因为线程阻塞会让出 CPU 资源, 从内核态转换为用户态, 然后执行的时候再次转换为内核态, 这个过程中, CPU 要切换线程, 看这个线程上次执行到哪儿了, 这次应该从哪儿开始, 相关的变量有哪些之类的, 这就是所谓的执行上下文, 这个上下文的切换对宝贵的 CPU 资源来说是 "无用功", 因为这是在为执行做准备条件, 如果 CPU 大量的时间用在这些 "无用功" 上, 当然也就出活儿少, 也就影响执行效率了.
synchronized 就是这样, 默认开始是偏向锁, 有竞争就逐渐升级, 最终可能是重量级锁的一个过程. 锁定的区域就是对象的 Mark Word 的内容.
总结: 为什么偏向锁跟轻量级锁相对来说速度快, 就是因为这两个没有这个线程切换的过程. 偏向锁是直接自己一直在用, 相当于没有同步操作, 轻量级锁是看了下有人在用, 自己觉得他们用的时间应该不长, 我就死循环等一下你们吧. 大概就是这么个逻辑. 当然了, 死循环结束, 发现你丫还没执行完, 没的说, 升级成重量级锁吧.
来源: https://www.cnblogs.com/nevermorewang/p/9864797.html