synchronized 解决方案

互斥

为了避免临界区的竞态条件发生,有多种手段可以达到目的。

  1. 阻塞式的解决方案:synchronizedLock
  2. 非阻塞式的解决方案:原子变量

synchronized,来解决上述问题,即俗称的【对象锁】,它采用互斥的方式让同一时刻至多只有一个线程能持有

【对象锁】,其它线程再想获取这个【对象锁】时就会阻塞住。这样就能保证拥有锁的线程可以安全的执行临界区

内的代码,不用担心线程上下文切换

synchronized实际上是用对象锁保证了临界区内代码的原子性,临界区内的代码是不可分割的,不会被线程切换所打断


考察synchronized锁住的是哪个对象

1.锁住的是n1-(this)

  1. @Slf4j(topic = "c.Number")
  2. class Number{
  3. public synchronized void a() {
  4. log.debug("1");
  5. }
  6. public synchronized void b() {
  7. log.debug("2");
  8. }
  9. }
  10. public static void main(String[] args) {
  11. Number n1 = new Number();
  12. new Thread(()->{ n1.a(); }).start();
  13. new Thread(()->{ n1.b(); }).start();
  14. }

2.若synchronized加在静态方法上,则锁住的是当前类的运行时类对象(.class)


变量的线程安全分析:

1.成员变量和静态变量是否线程安全?

如果它们没有被共享,则是线程安全的

如果它们被共享了,根据它们的状态是否能被改变,又分为两种情况

  1. 如果只有读操作,则线程安全
  2. 如果有读写操作,则这段代码是临界区,需要考虑线程安全

2.局部变量是否线程安全

局部变量是线程安全的

但局部变量引用的对象则未必

  1. 如果该对象没有逃离方法的作用访问。它是线程安全的
  2. 如果该对象逃离方法的作用范围,需要考虑线程安全(如使用return

3.线程安全的多个方法组合在一起不一定线程安全,需要在组合方法上添加线程安全保护

4.不可变类线程安全
  1. StringInteger都是不可变类,内部的状态不可变,因此线程安全

java对象的结构:

1646818977749.png

1.对象头:
  1. ①:Mark Wordhashcode、分代年龄、状态、加锁状态位
  2. ②:Klass word:类型指针(什么类型的对象)

2.对象体:
  1. 成员变量的信息

Java 对象头:

以 32 位虚拟机为例

1646816188296.png

64 位虚拟机 Mark Word

1646816210489.png

Monitor概念

Monitor被翻译为监视器或者管程

每个Java对象都可以关联一个Monitor对象,如果使用synchronized给对象上锁(重量级)之后,该对象头的Mark Word中就被设置指向Monitor的指针

Monitor结构如下:

1646817043063.png

1.刚开始Monitor种Owner为null

2.当Thread-2执行synchronized(obj) 就会将Monitor的所有者Owner置为Thread-2,Monitor种只能有一个Owner

3,在Thread-2上锁的过程种,如果Thread-3,4,5也来执行synchronized(obj) ,就会进入EntryList BLOCKED

4.Thread-2执行完同步代码块的内容,然后唤醒EntryList种的等待线程来竞争锁,竞争是非公平的

5.图中WaitSet种的Thread-0,Thread-1是之前获得过锁,当条件不满足WAITING状态的线程,后面讲wait-notify

时分析

  1. **注意:**
  2. synchronized必须时进入同一个monitor才有上述效果
  3. 不加synchronized的对象不会关联监视器,不遵从以上规则

synchronized原理进阶:

轻量级锁:

使用场景:如果一个对象虽然有多线程访问,但多线程访问的时间是错开的(也就是没有竞争),那么可以使用轻量级锁

轻量级锁对使用者是透明的,语法仍然为synchronized

  1. 1.创建锁记录(Lock Record),每个线程的栈帧都会包含一个锁的记录,内存可以存储锁对象的Mark Word

1646819269864.png

  1. 2.让锁记录中Object reference 指向锁对象,并且尝试用cas替换Object中的Mark Word,将Mark Word的值存入锁记录(若Mark Word值已经被其他线程所修改则替换失败)

1646819423103.png

  1. 3.如果cas替换成功,对象头中存储了锁记录地址和状态 00,表示由该线程给对象加锁

1646819563598.png

如果cas失败:
  1. 1.其他线程已经持有了该Object的轻量级锁,此时表明有竞争,进入锁膨胀过程
  2. 2.如果是自己执行了synchronized锁重入,那么再添加一条Lock Record作为重入的计数<br />![1646820022734.png](https://cdn.nlark.com/yuque/0/2022/png/26737039/1647331695203-2b372579-5ee7-4efc-ae87-c2252e2679c6.png#clientId=u46ad077f-3ef3-4&crop=0&crop=0&crop=1&crop=1&from=ui&id=lrT5o&margin=%5Bobject%20Object%5D&name=1646820022734.png&originHeight=359&originWidth=597&originalType=binary&ratio=1&rotation=0&showTitle=false&size=109184&status=done&style=none&taskId=u75c465ef-1c37-456f-8a81-ec82f3f14d3&title=)

解锁:
  1. 1.当退出synchronized代码块(解锁时) 如果有取值为null的锁记录,表示有重入,这时重置锁记录,表示重入计数减1

1646820139830.png

  1. 2.当退出synchronized代码块时,锁记录的值不为null,这是使用casMark Word的值恢复给对象头
  2. 成功,则解锁成功
  3. 失败,说明轻量级锁进行了锁膨胀或已经升级为重量级锁,进入重量级锁解锁流程

2.锁膨胀:

如果在尝试加轻量级锁的过程中,CAS操作无法成功,这时一种情况就是有其他线程为此对象加上了轻量级锁(有竞争),这时需要进行锁膨胀,将轻量级锁变为重量级锁

1.当Thread-1进行轻量级加锁时,Thread-0已经对该对象加了轻量级锁

1646820530427.png

2.这时Thread-1加轻量级锁失败,进入锁膨胀流程
  1. ①:即为Object对象申请Monitor锁,让Object指向重量级锁地址(后两位数字变为10
  2. ②:然后自己进入MonitorEntryList BLOCKED

1646820663799.png

3.当Thread-0退出同步块解锁时,使用cas将Mark Word的值恢复给对象头,失败,这时会进入重量级解锁流程:即按照Monitor地址找到Monitor对象,将Owner设置为null,唤醒EntryList中的BLOCKED线程

3.自旋优化:(比较适合多核CPU)

重量级锁竞争的时候,还可以使用自旋来进行优化,如果当前线程自旋成功(即这时候持有锁线程已经退出了同步块,释放了锁),这时当前线程就可以避免阻塞 (阻塞会发生上下文切换,消耗性能)

自旋成功的情况:

1646823854008.png

自旋失败的情况:

1646823915727.png

注意:

1.在java6之后自旋锁是自适应的,比如对象刚刚的一次自选操作成功过,那么认为这次自旋成功的可能性会高,就会多自旋几次;反之,就是少自旋甚至不自旋,比较只能

2.自旋会占用CPU时间,单核CPU自旋就是浪费,多核CPU自旋才能发挥优势

3.java 7 以后不能控制是否开启自旋功能


4.偏向锁:

轻量级锁在没有竞争时(就自己这个线程),每次重入仍然需要执行CAS操作(性能损耗)

java6中引入了偏向锁来做进一步优化:只有第一次使用CAS将线程ID设置到对象的Mark Word头,之后发现这个线程ID是自己的就没有表示竞争,不用重新CAS。以后只要不发生竞争,这个对象就归该线程所有

1646824479431.png

4.1 偏向状态:

回忆对象头格式:

1646824815055.png

一个对象创建时:

  1. 1.如果开启了偏向锁(默认开启),那么对象创建后,markword值的后三位为101(biased_lock字段表示是否开启偏向锁,1为开启),此时他的threadepocage都为0
  2. 2.偏向锁是默认延迟的,不会在程序启动时立即生效,如果想避免延迟,可以加参数来禁用延迟
  3. 3.如果没有开启偏向锁,那么对象创建后,markword值为001,此时他的hashcodeage、都为0,第一次用到hashcode时才会赋值
  4. 4.处于偏向锁的对象解锁后,线程id仍存储于对象头中

注意:

  1. 当我们的程序本身就是线程很多,那么偏向锁就不适用,可以通过参数来禁用:
  2. 添加VM参数: -xx:-UseBiasedLocking来禁用偏向锁

4.2 撤销偏向锁—-调用对象的hashcode方法

当我们调用对象的hashcode方法就会禁用掉偏向锁

原因:通过对象头的结构可知,Mark Word 的空间是有限的,当我们开启偏向锁,就会存储线程id,而没有空间存储hashcode,所以我们调用hashcode方法,就会关闭偏向锁,清除掉线程id替换为hashcode

为什么轻量级锁和重量级锁不会有上述问题?
  1. 轻量级锁会存储在线程栈帧的锁记录中
  2. 重量级锁会将hashcode存储在Monitor对象中,解锁时再进行还原
  3. 只有偏向锁没有额外的空间,所以会有上述问题

4.3 撤销偏向锁—-其他线程使用对象

当其他线程使用偏向锁对象时,会将偏向锁升级为轻量级锁

4.4 撤销偏向锁—-调用wait/notify

只有重量级锁才有这两个方法,所以调用会将偏向锁或轻量级锁转换为重量级锁

4.5 批量重偏向

如果对象虽然被多个线程访问,但是没有竞争,这时偏向了线程T1的对象仍有机会重新偏向T2,重偏向会重置对象的Thread ID(线程ID)

当撤销偏向锁的阈值超过了20次,jvm会觉得是不是偏向错了呢?于是会给在这些对象加锁时重新偏向至加锁线程

4.6 批量撤销

当撤销偏向锁的阈值超过了40次,jvm会觉得自己偏向错了,根本就不该偏向,就会把整个类的所有对象变为不可偏向的,新建的对象也是不可偏向的

5.锁消除

java运行时有一个JIT即时编译器,会对java字节码进一步优化,反复运行的代码超过一定的阈值就会进行即时优化,当发现变量不会存在线程安全问题,JIT就会把多余的synchronized去掉
1646830802690.png


6.锁的粗化

  1. public void test1(){
  2. for(int i=0;i<1000;i++){
  3. synchronized(Test.class){
  4. sout("hello");
  5. }
  6. }
  7. }

锁粗化后:扩大锁的范围,避免反复加锁和释放锁

  1. public void test1(){
  2. synchronized(Test.class){
  3. for(int i=0;i<1000;i++){
  4. sout("hello");
  5. }
  6. }
  7. }