Synchronized
基础
作用:保证被它修饰的方法或者代码块在任意时刻只有一个线程执行。
实现同步的方式
- 静态同步方法
- 同步代码块:通过加入字节码monitorenter和monitorexit指令来实现monitor的获取和释放,也就是需要通过字节码显式的获取和释放monitor来实现同步
- 普通同步方法:检查方法的ACC_SYNCHRONIZED标志是否被设置,如果设置了则线程需要获取monitor,执行完毕看线程再释放monitor,也就是不需要JVM显式的实现
解释synchronized是非公平锁
- 线程尝试获取monitor的所有权,如果获取失败说明monitor被其他线程占用,则将线程加入到同步队列中,等待其他线程释放monitor,当其他线程释放monitor后,有可能刚好有线程获取monitor的所有权,那么系统会将monitor的所有权给这个线程,而不会去唤醒同步队列的第一个节点去获取,所以synchronized是非公平的。
缺点:
- monitor的下层是依赖于操作系统的Mutex Lock来实现的。根据 java 线程模型事实上是对操作系统线程的映射,所以每当挂起或者唤醒一个线程都要切换到操作系统的内核态;这个操作时比较重量级的,在某些情况下,甚至切换时间本身就会超出线程执行任务的时间,这样synchronized就会对程序性能产生影响。
参考:
锁优化
在JVM中,对象在内存中分为三块区域:
- 对象头:Mark Word(标记字段) 和 Klass Point(类型指针)组成
- 实例数据
- 字节对齐:为了内存的IO性能,jvm要求对象起始地址必须是8字节的整数倍。
Mark Word(标记字段):用于存储对象自身的运行时数据,例如存储对象的HashCode,分代年龄、锁标志位等信息,是synchronized实现轻量级锁和偏向锁的关键。
JVM先利用对象头实现锁的功能(引入适应性自旋、锁消除、锁粗化、偏向锁、轻量级锁等概念),如果线程的竞争过大则会将锁膨胀为重量级锁,也就是使用monitor对象。
下面的偏向锁和轻量级锁都是基于Mark word实现,而不是直接使用monitor实现。这是JVM对锁的优化
偏向锁(01)
- 在没有多线程竞争的情况下,尽量减少不必要的轻量级锁的执行。轻量级锁的获取及释放依赖多次CAS原子指令,而偏向锁只依赖一次CAS原子指令。但是在多线程竞争时,需要进行偏向锁撤销步骤,因此其撤销的开销必须小于节省下来的CAS开销,否则偏向锁并不能带来收益。
- 始终只有一个线程在执行同步块,在没有锁竞争的情况下使用。升级为轻量级锁的时候需要撤销偏向锁,撤销偏向锁的时候会导致stop the word 的操作。
过程:
- 检查Mark word是否为可偏向锁状态,1表示支持可偏向锁,0表示不支持可偏向锁;
- 如果Mark Word前23 bit没有存储线程ID(无锁),需要执行 initial lock,使用CAS操作,把当前线程ID设置到锁对象的Mark Word中;
- 检查Mark Word存储的线程ID是否为当前线程ID,如果是则执行同步块。
- 如果不是则当该锁的线程到达安全点(没有字节码正在执行)之后,暂停拥有偏向锁的线程,判断锁对象是否处于被锁定状态,撤销偏向锁后恢复到 无锁(01) 或者 轻量级锁 (00) 状态。
轻量级锁(00)
过程:
- 在线程进入同步方法、同步代码块的时候,如果同步对象锁的状态为无锁(锁标志位为 01,是否为偏向锁为 0)状态,JVM会首先将在当前线程的栈帧中建立一个名为锁记录的空间,用于存储锁对象目前的Mark Word的拷贝以及Owner指针。
- 将对象头中的Mark Word拷贝复制到锁记录中;
- JVM会使用CAS操作尝试将对象的Mark Word中的前 30 bit 将生成一个指针,指向持有该对象锁的LockRecord,并将Lock Record中的owner指针指向对象的MarkWord;进而实现了线程与对象锁的双向绑定;
- 如果更新操作失败,JVM会检查当前的Mark Word中是否存在指向当前线程的锁记录的指针,如果有,则说明该锁已经被获取,可以直接调用;如果没有,则说明该锁对象被其他线程抢占了。若当前只有一个等待线程,则该线程进行通过自旋等待。如果自旋超过一定次数 或者 一个线程持有锁,一个在自旋,又有第三个访问时,则膨胀为重量级锁 并更换为存储monitor的指针。
重量级锁(10)
获取锁的过程:使用MonitorEnter指令
- 当monitor计数器为0,意味着目前还没有被获得,那这个线程就会立刻获得然后把锁计数器 + 1;其他线程等待;
- 当monitor获得了这个锁的所有权,重入了这把锁,锁计数器就会累加,变成 2,并且随着重入的次数,会一直累加;
释放锁的过程:使用MonitorExit指令
- 当monitor计数器 减 1,计数器仍不为0,表示是重入进来的,当前线程仍继续持有这把锁的所有权;
- 当monitor计数器 减 1,计数器为0,表示当前线程不再拥有该monitor的所有权。
自旋锁
- 当前线程请求某个锁的时候,这个锁被其他线程占用。当前线程并不会马上进入阻塞状态,而是循环请求锁,一直尝试请求占用CPU的时间片。如果到达指定时间仍没有获得锁,仍会被挂起。完全在用户空间解决,不需要进行系统的中断和现场恢复,所以效率很高。
自适应性自旋锁:自旋的时间不再固定,取决于前一次在同一个锁上的自旋时间以及锁的拥有者的状态决定。如果线程自旋成功了,下次自旋的次数会增多。反之则减少
锁消除
JVM即使编译器运行时,对一些代码上要求同步,但是被检测到不可能存储有共享数据竞争的锁进行消除。
锁粗化
在使用锁的时候,需要让同步块的作用范围尽可能小。为了使需要同步的操作数量减少,如果存在锁竞争,那么等待锁的线程也能尽快拿到锁。
锁膨胀
无锁-> 偏向锁 -> 轻量级锁 -> 重量级锁
层级实现
- java语言层级:synchronize关键字实现加锁;
- 字节码层级:monitor、moniterenter、moniterexit字节码实现加锁;
- 操作系统层级:申请操作系统的metux互斥锁加锁实现,会阻塞线程;(信号量里面的PV操作参考:https://blog.nowcoder.net/n/5d299ef8755945a4a183f2b9cac1702f)
- 硬件层级:通过lock comxchg (CMS)实现获取锁资源,lock前缀指令保障一致性。
缺陷
- 可中断获取锁:使用synchronized关键字获取锁的时候,如果线程没有获取到被阻塞了,这个时候该线程是不会响应中断 ( interrupt )的,而使用 lock.lockInterruptibly() 获取锁时被中断,线程将抛出中断异常;
- 可非阻塞获取锁:使用synchronized关键字获取锁时,如果没有获取成功,只有被阻塞,而使用 locl.tryLock() 获取锁时,如果没有获取成功也不会被阻塞而是直接返回 false;
- 可限定获取锁的超时时间:使用lock.tryLock( long time, TimeUnit unit );
- 同一个对象上可以有多个等待队列 ( Condition,类似于Object.wait(),支持公平锁模式 )。
补充:
- 多线程竞争一个锁时,其余未得到锁的线程只能不停尝试获得锁,而不能中断。高并发的情况下会导致性能下降。ReetrantLock的lockInterruptibly() 方法可以优先考虑响应中断;
- 一个线程等待时间过长,可以中断可以,然后ReetrantLock响应这个中断,不再让这个线程继续等待。有了这个机制,使用ReetrantLock时就不会像synchronized那样产生死锁了。
拓展
为什么synchronized无法禁止指令重排,却能保证有序性?
- 指令重排:在不影响最终的运行结果下,合理的进行优化执行顺序;
- 因为synchronized通过排他锁的方式就保证了同一时间内,被synchronized修改的代码是单线程执行的。所以满足as -if- serial语义,也就是可以适当的重排但是不影响最终的结果。
早高峰时间,大量用户使用synchronized,持续10分钟后很少会继续使用。会造成什么问题?
- synchronized通过锁膨胀到达重量级锁,由于过程不可逆,导致一直是重量级锁。
hshuo的面试之路 文章被收录于专栏
作者目标是找到一份Java后端方向的工作 此专栏用来记录从Bilibili、书本、其他优质博客上面学习的内容 用于巩固、总结内容 主要包含Docker、Dubbo、Java基础、JUC、Maven、MySQL、Redis、SpringBoot、SpringCloud、数据结构、杂文、算法、计算机网络、操作系统、设计模式等相关内容