前序
线程安全问题的主要诱因
存在共享数据(也称临界资源)
存在多条线程共同操作这些共享数据
解决方法: 同一时刻有且只有一个线程在操作共享数据, 其他线程必须等到该线程处理完数据后再对共享数据进行操作
互斥锁的特征
互斥性: 即在同一时间只允许一个线程持有某个对象锁, 通过这种特性来实现多线程协调机制, 这样在同一时间只有一个线程对需要同步的代码块 (复合操作) 进行访问. 互斥性也称为操作的原子性.
可见性: 必须确保在锁被释放之前, 对共享变量所做的修改, 对于随后获得该锁的另一个线程是可见的(即在获得锁时应该获得最新共享变量的值), 否则另一个线程可能是在本地缓存的某个副本上继续操作, 从而引起不一致.
注: synchronized 锁的不是代码, 锁的是对象
获取锁的分类: 获取对象锁, 获取类锁
获取对象锁的两种用法:
同步代码块(synchronized(this),synchronized(类实例对象)), 锁是小括号中的实例对象
同步非静态方法(synchronized method) 锁是当前对象的实例对象
获取类锁的两种用法:
同步代码块(synchronized(类. class)), 锁是小括号中的类对象(Class 对象)
同步非静态方法(synchronized static method) 锁是当前对象的类对象(Class 对象)
类锁和对象锁在锁同一个对象的时候表现行为是一样的, 因为 class 也是对象锁, 只是比较特殊, 所有的实例共享同一个类(同一个 class 对象)
如果锁的是不同对象 (同一个 class 的不同实例) 表现就不一样了, 类锁是全同步的, 对象锁是按对象区分同步的.
类锁和对象锁互不干扰的, 因为对象实例和类是两个不同的对象.
对象锁和类锁的终结
有线程访问对象的同步代码块时, 另外的线程可以访问该对象的非同步代码块
若锁住的是同一个对象, 一个线程在访问对象的同步代码块时, 另一个访问对象的同步代码块的线程会被阻塞
若锁住的是同一个对象, 一个线程在访问对象的同步方法时候另一个访问对象同步方法的线程会被阻塞
若锁住的是同一个对象, 一个线程在访问对象的同步代码块时, 另一个线程访问对象同步方法会被阻塞, 反之亦然
同一个类的不同对象锁互不干扰
类锁由于是一种特殊的对象锁, 因此表现和上述 1,2,3,4 一致, 而由于一个类只有一把对象锁, 所以同一个类的不同对象使用类锁将会是同步的
类锁和对象锁互不干扰
乐观锁
乐观锁是一种乐观思想, 即认为读多写少, 遇到并发写的可能性低, 每次去拿数据的时候都认为别人不会修改, 所以不会上锁, 但是在更新的时候会判断一下在此期间别人有没有去更新这个数据, 采取在写时先读出当前版本号, 然后加锁操作(比较跟上一次的版本号, 如果一样则更新), 如果失败则要重复读 - 比较 - 写的操作.
java 中的乐观锁基本都是通过 CAS 操作实现的, CAS 是一种更新的原子操作, 比较当前值跟传入值是否一样, 一样则更新, 否则失败.
悲观锁
悲观锁是就是悲观思想, 即认为写多, 遇到并发写的可能性高, 每次去拿数据的时候都认为别人会修改, 所以每次在读写数据的时候都会上锁, 这样别人想读写这个数据就会 block 直到拿到锁. java 中的悲观锁就是 Synchronized,AQS 框架下的锁则是先尝试 cas 乐观锁去获取锁, 获取不到, 才会转换为悲观锁, 如 RetreenLock.
阻塞代价
java 的线程是映射到操作系统原生线程之上的, 如果要阻塞或唤醒一个线程就需要操作系统介入, 需要在户态与核心态之间切换, 这种切换会消耗大量的系统资源, 因为用户态与内核态都有各自专用的内存空间, 专用的寄存器等, 用户态切换至内核态需要传递给许多变量, 参数给内核, 内核也需要保护好用户态在切换时的一些寄存器值, 变量等, 以便内核态调用结束后切换回用户态继续工作.
如果线程状态切换是一个高频操作时, 这将会消耗很多 CPU 处理时间;
如果对于那些需要同步的简单的代码块, 获取锁挂起操作消耗的时间比用户代码执行的时间还要长, 这种同步策略显然非常糟糕的.
synchronized 会导致争用不到锁的线程进入阻塞状态, 所以说它是 java 语言中一个重量级的同步操纵, 被称为重量级锁, 为了缓解上述性能问题, JVM 从 1.5 开始, 引入了轻量锁与偏向锁, 默认启用了自旋锁, 他们都属于乐观锁.
深入理解 synchronized 底层实现原理:
Java 对象头和 Monitor 是实现 synchronized 的基础
hotspot 中对象在内存的布局是分 3 部分 :
对象头
实例数据
对其填充
这里主要讲对象头: 一般而言 synchronized 使用的锁对象是存储在对象头里的, 对象头是由 Mark Word 和 Class Metadata Address 组成
要详细了解 java 对象的结构点击: https://blog.csdn.net/zqz_zqz/article/details/70246212
虚拟机位数 | 头对象结构 | 说明 |
---|---|---|
32/64bit | Mark Word | 默认存储对象的 hashCode, 分代年龄、锁类型、锁标志位等信息 |
32/64bit | Class Metadata Address | 类型指针指向对象的类元数据,JVM 通过这个指针确定该对象是哪个类型的数据 |
mark Word 存储自身运行时数据, 是实现轻量级锁和偏向锁的关键, 默认存储对象的 hasCode, 分代年龄, 锁类型, 锁标志位等信息.
mark Word 数据的长度在 32 位和 64 位的虚拟机 (未开启压缩指针) 中分别为 32bit 和 64bit, 它的最后 2bit 是锁状态标志位, 用来标记当前对象的状态, 对象的所处的状态, 决定了 markword 存储的内容, 如下表所示:
由于对象头的信息是与对象定义的数据没有关系的额外存储成本, 所以考虑到 jvm 的空间效率, mark Word 被设计出一个非固定的存储结构, 以便存储更多有效的数据, 它会根据对象本身的状态复用自己的存储空间(轻量级锁和偏向锁是 java6 后对 synchronized 优化后新增加的)
Monitor: 每个 Java 对象天生就自带了一把看不见的锁, 它叫内部锁或者 Monitor 锁(监视器锁). 上图的重量级锁的指针指向的就是 Monitor 的起始地址.
每个对象都存在一个 Monitor 与之关联, 对象与其 Monitor 之间的关系存在多种实现方式, 如 Monitor 可以和对象一起创建销毁, 或当线程获取对象锁时自动生成, 当线程获取锁时 Monitor 处于锁定状态.
Monitor 是虚拟机源码里面用 C++ 实现的.
源码解读:_WaitSet 和_EntryList 就是之前学的等待池和锁池,_owner 是指向持有 Monitor 对象的线程. 当多个线程访问同一个对象的同步代码的时候, 首先会进入到_EntryList 集合里面, 当线程获取到对象 Monitor 后就会进入到_object 区域并把_owner 设置成当前线程, 同时 Monitor 里面的_count 会加一. 当调用 wait 方法会释放当前对象的 Monitor,_owner 恢复成 null,_count 减一, 同时该线程实例进入_WaitSet 集合中等待唤醒. 如果当前线程执行完毕也会释放 Monitor 锁并复位对应变量的值.
接下来是字节码的分析:
- package interview.thread;
- /**
- * 字节码分析 synchronized
- * @Author: hankli
- * @Date: 2019/5/20 13:50
- */
- public class SyncBlockAndMethod {
- public void syncsTask() {
- synchronized (this) {
- System.out.println("Hello");
- }
- }
- public synchronized void syncTask() {
- System.out.println("Hello Again");
- }
- }
然后控制台输入
javac thread/SyncBlockAndMethod.java
然后反编译
javap -verbose thread/SyncBlockAndMethod.class
先看看 syncsTask 方法里的同步代码块:
从字节码中可以看出 同步代码块 使用的是 monitorenter 和 monitorexit , 当执行 monitorenter 指令时当前线程讲试图获取对象的锁, 当 Monitor 的 count 为 0 时将获的 monitor, 并将 count 设置为 1 表示取锁成功. 如果当前线程之前有这个 monitor 的持有权它可以重入这个 Monnitor.monitorexit 指令会释放 monitor 锁并将计数器设为 0. 为了保证正常执行 monitorenter 和 monitorexit 编译器会自动生成一个异常处理器, 该处理器可以处理所有异常. 主要保证异常结束时 monitorexit(字节码中多了个 monitorexit 指令的目的)释放 monitor 锁
注: 重入是从互斥锁的设计上来说的, 当一个线程试图操作一个由其他线程持有的对象锁的临界资源时, 将会处于阻塞状态, 当一个线程再次请求自己持有对象锁的临界资源时, 这种情况属于重入. 就像如下情况: hello2 也是会输出的, 并不会锁住.
再看看 syncTask 同步方法:
解读: 这个字节码中没有 monitorenter 和 monitorexit 指令并且字节码也比较短, 其实方法级的同步是隐式实现的(无需字节码来控制)ACC_SYNCHRONIZED 是用来区分一个方法是否同步方法, 如果设置了 ACC_SYNCHRONIZED 执行线程将持有 monitor, 然后执行方法, 无论方法是否正常完成都会释放调 monitor, 在方法执行期间, 其他线程都无法在获得这个 monitor. 如果同步方法在执行期间抛出异常而且在方法内部无法处理此异常, 那么这个 monitor 将会在异常抛到方法之外时自动释放.
java6 之前 Synchronized 效率低下的原因:
在早期版本 Synchronized 属于重量级锁, 性能低下, 因为监视器锁 (monitor) 是依赖于底层操作系统的的 MutexLock 实现的.
而操作系统切换线程时需要从用户态转换到核心态, 时间较长, 开销较大
java6 以后 Synchronized 性能得到了很大提升(hotspot 从 jvm 层面做了较大优化, 减少重量级锁的使用):
Adaptive Spinning 自适应自旋
Lock Eliminate 锁消除
Lock Coarsening 锁粗化
Lightweight Locking 轻量级锁
Biased Locking 偏向锁
......
自旋锁:
许多情况下, 共享数据的锁定状态持续时间较短, 切换线程不值得
通过让线程执行 while 循环等待锁的释放, 不让出 CPU
java4 就引入了, 不过默认是关闭的, java6 后默认开启的
自旋本质和阻塞状态并不相同, 如果锁占用时间非常短, 那自旋锁性能会很好
缺点: 若锁被其他线程长时间占用, 会带来许多性能上的开销, 因为自旋一直会占用 CPU 资源且白白消耗掉 CPU 资源.
如果线程超过了限定次数还没有获取到锁, 就该使用传统方式挂起线程(可以设置 VM 的 PreBlockSpin 参数来更改限定次数)
来源: https://yq.aliyun.com/articles/709800