Java多线程(五)——synchronized关键字原理
iwehdio的博客园:https://www.cnblogs.com/iwehdio/
学习自:
1、Java对象的内存表示
- Java 对象在内存中的表示方法:
-
内存中的对象一般由三部分组成,分别是对象头、对象实际数据和对齐填充。
-
对象头包含 Mark Word、Class Pointer和 Length 三部分。
- 标记字段Mark Word 记录了对象关于锁的信息,垃圾回收信息等。
- Class Pointer 用于指向对象对应的 Class 对象(其对应的元数据对象)的内存地址。
- Length只适用于对象是数组时,它保存了该数组的长度信息。
-
对象实际数据包括了对象的所有成员变量,其大小由各个成员变量的大小决定。
-
对齐填充表示最后一部分的填充字节位,这部分不包含有用信息。
-
synchronized
锁使用的就是对象头的 Mark Word 字段中的一部分。- Mark Word 中的某些字段发生变化,就可以代表锁不同的状态。
- 由于锁的信息是记录在对象里的,也往往会说锁住对象这种表述。
-
无锁状态的 Mark Word 字段:
- 对象头的 Mark Word 字段分为四个部分:
- 对象的 hashCode ;
- 对象的分代年龄,这部分用于对对象的垃圾回收;
- 是否为偏向锁位,1代表是,0代表不是;
- 锁标志位,这里是 01。
- 对象头的 Mark Word 字段分为四个部分:
2、synchronized原理
- 直接作为关键字修饰在方法上,将整个方法作为同步代码块:
- 编译器会为该方法自动生成了一个
ACC_SYNCHRONIZED
关键字用来标识。 - 在 JVM 进行方法调用时,当发现调用的方法被
ACC_SYNCHRONIZED
修饰,则会先尝试获得锁。 - 同步方法的时候,一旦执行到这个方法,就会先判断是否有标志位,然后,ACC_SYNCHRONIZED会去隐式调用monitorenter和monitorexit。
- 编译器会为该方法自动生成了一个
- 修饰在同步代码块上:
- 编译时在代码块开始前生成对应的1个
monitorenter
指令,代表同步块进入。2个monitorexit
指令,代表同步块退出。
- 编译时在代码块开始前生成对应的1个
- 这两种方法底层都需要一个 reference 类型的参数,指明要锁定和解锁的对象。
- 如果
synchronized
明确指定了对象参数,那就是该对象。 - 如果没有明确指定,那就根据修饰的方法是实例方法还是类方法,取对应的对象实例或类对象(Java 中类也是一种特殊的对象)作为锁对象。
- 每个对象维护着一个记录着被锁次数的计数器。当一个线程执行
monitorenter
,该计数器自增从 0 变为 1; - 当一个线程执行
monitorexit
,计数器再自减。当计数器为 0 的时候,说明对象的锁已经释放。
- 如果
- 为什么会有两个
monitorexit
指令呢?- 正常退出,得用一个
monitorexit
吧,如果中间出现异常,锁会一直无法释放。所以编译器会为同步代码块添加了一个隐式的try-finally
异常处理,在finally
中会调用monitorexit
命令最终释放锁。
- 正常退出,得用一个
重量级锁
-
重量级锁对应对象的 Mark Word:
-
该对象头的 Mark Word 分为两个部分。第一部分是指向重量级锁的指针,第二部分是锁标记位。
-
指向重量级锁的指针就是所谓的同步监视器
monitor
。这个监视器其实也就是监控锁有没有释放,释放的话会通知下一个等待锁的线程去获取。 -
可以将 monitor 简单理解成两部分,第一部分表示当前占用锁的线程,第二部分是等待这把锁的线程队列。如果当前占用锁的线程把锁释放了,那就需要在线程队列中唤醒下一个等待锁的线程。
-
是阻塞或唤醒一个线程需要依赖底层的操作系统来实现,Java 的线程是映射到操作系统的原生线程之上的。而操作系统实现线程之间的切换需要从用户态转换到核心态,这个状态转换需要花费很多的处理器时间,甚至可能比用户代码执行的时间还要长。由于这种效率太低,Java 后期做了改进。
-
-
CAS算法:
- 该算法认为线程之间对变量的操作进行竞争的情况比较少。
- 算法的核心是对当前读取变量值
E
和内存中的变量旧值V
进行比较。 - 如果相等,就代表其他线程没有对该变量进行修改,就将变量值更新为新值
N
。 - 如果不等,就认为在读取值
E
到比较阶段,有其他线程对变量进行过修改,不进行任何操作。
- 当线程运行 CAS 算法时,该运行过程是原子操作,原子操作的含义就是线程开始跑这个函数后,运行过程中不会被别的程序打断。
偏向锁
-
JDK 1.6 中提出了偏向锁的概念。该锁提出的原因是,开发者发现多数情况下锁并不存在竞争,一把锁往往是由同一个线程获得的。如果是这种情况,不断的加锁解锁是没有必要的。
-
因此开发者设计了偏向锁。偏向锁在获取资源的时候,会在资源对象上记录该对象是否偏向该线程。
-
偏向锁并不会主动释放,这样每次偏向锁进入的时候都会判断该资源是否是偏向自己的,如果是偏向自己的则不需要进行额外的操作,直接可以进入同步操作。
-
偏向锁的 Mark Word结构:
- 偏向锁对应的 Mark Word 包含该偏向锁对应的线程 ID、偏向锁的时间戳和对象分代年龄。
-
偏向锁的申请流程:
- 首先需要判断对象的 Mark Word 是否属于偏向模式,如果不属于,那就进入轻量级锁判断逻辑。否则继续下一步判断;
- 判断目前请求锁的线程 ID 是否和偏向锁本身记录的线程 ID 一致。如果一致,继续下一步的判断,如果不一致,跳转到步骤4;
- 判断是否需要重偏向,如果不用的话,直接获得偏向锁;
- 利用 CAS 算法将对象的 Mark Word 进行更改,使线程 ID 部分换成本线程 ID。如果更换成功,则重偏向完成,获得偏向锁。如果失败,则说明有多线程竞争,升级为轻量级锁。
- 在执行完同步代码后,线程不会主动去修改对象的 Mark Word,让它重回无锁状态。所以一般执行完
synchronized
语句后,如果是偏向锁的状态的话,线程对锁的释放操作可能是什么都不做。
-
匿名偏向锁:
- 在 JVM 开启偏向锁模式下,如果一个对象被新建,在四秒后,该对象的对象头就会被置为偏向锁。
- 一般来说,当一个线程获取了一把偏向锁时,会在对象头和栈帧中的锁记录里不仅说明目前是偏向锁状态,也会存储锁偏向的线程 ID。
- 在 JVM 四秒自动创建偏向锁的情况下,线程 ID 为0。
- 由于这种情况下的偏向锁不是由某个线程求得生成的,这种情况下的偏向锁也称为匿名偏向锁。
-
批量重偏向和批量撤销:
- 在生产者消费者模式下,生产者线程负责对象的创建,消费者线程负责对生产出来的对象进行使用。
- 当生产者线程创建了大量对象并执行加偏向锁的同步操作,消费者对对象使用之后,会产生大量偏向锁执行和偏向锁撤销的问题。
- 以类为单位,为每个类维护一个偏向锁撤销计数器,每一次该类的对象发生偏向撤销操作时,该计数器计数 +1,当这个计数值达到重偏向阈值时,JVM 就认为该类可能不适合正常逻辑,适合批量重偏向逻辑。
轻量级锁
-
轻量级锁的设计初衷对于绝大部分的锁,在整个同步周期内都是不存在竞争的。所以它的设计出发点也在线程竞争情况较少的情况下。
-
轻量级锁的 Mark Word:
- 第一部分是指向栈中的锁记录的指针,第二部分是锁标记位,针对轻量级锁该标记位为 00。
-
偏向锁升级为轻量级锁:
- 如果当前这个对象的锁标志位为 01(即无锁状态或者偏向锁状态),线程在执行同步块之前,JVM 会先在当前的线程的栈帧中创建一个 Lock Record,包括一个用于复制对象头中的 Mark Word 以及一个指向对象的指针。
- 然后 JVM 会利用 CAS 算法对这个对象的 Mark Word 进行修改。如果修改成功,那该线程就拥有了这个对象的锁。
- 如果 CAS 失败,那就说明同时执行 CAS 操作的线程可不止一个了, Mark Word 也做了更改。
- 首先虚拟机会检查对象的 Mark Word 字段指向栈中的锁记录的指针是否指向当前线程的栈帧。如果是,那就说明可能出现了类似
synchronized
中套synchronized
情况。这种情况下当前线程已经拥有这个对象的锁,可以直接进入同步代码块执行。 - 否则说明锁被其他线程抢占了,该锁还需要升级为重量级锁。
- 首先虚拟机会检查对象的 Mark Word 字段指向栈中的锁记录的指针是否指向当前线程的栈帧。如果是,那就说明可能出现了类似
- 但是不会直接升级为重量级锁,会先自旋的尝试获取轻量级锁,默认自旋10次后,在升级为重量级锁。
-
和偏向锁不同的是,执行完同步代码块后,需要执行轻量级锁的解锁过程:
- 通过 CAS 操作尝试把线程栈帧中复制的 Mark Word 对象替换当前对象的 Mark Word。
- 如果 CAS 算法成功,整个同步过程就完成了。
- 如果 CAS 算法失败,则说明存在竞争,锁升级为重量级锁。
iwehdio的博客园:https://www.cnblogs.com/iwehdio/