synchronized原理

初学习Java的时候,synchronized是我解决同步问题的神器,只需在方法上,或者在代码里面使用synchronized,就可以解决数据并发问题,但是,随着学习的进行知道synchronized是一个重量级锁,相对于Lock,它会显得笨重,所以后面渐渐的不再使用synchronized。 随着Javs SE 1.6对synchronized进行的各种优化后,synchronized 的实现变得更加灵活

1. 原理

使用过synchronized 的都知道,synchronized内置锁是一种对象锁(锁的是对象而非引用),作用粒度是对象,不同的使用方式,锁定的对象不同:

加锁的方式:

  • 同步实例方法,锁是当前实例对象
  • 同步类方法,锁是当前类对象
  • 同步代码块,锁是括号里面的对象

代码:

public class SynchronizedTest {
    public synchronized void test1(){

    }

    public void test(){
        synchronized (this){

        }
    }
}

字节码:

synchronized关键字被编译成字节码后会被翻译成monitorenter 和
monitorexit 两条指令分别在同步块逻辑代码的起始位置与结束位置。

那么 monitorenter / monitorexit 又是怎么工作的呢

我们知道,synchronized加锁加在对象上,锁的状态是记录在 对象头(Mark Word)中,如果要彻底说清synchronized 的实现,就要说一下 对象的内存模型了

2. java对象内存模型

HotSpot虚拟机中,对象在new出后,在内存中存储的布局可以分为三块区域:对象头(Header)、实例数据(Instance Data)和对齐填充(Padding)。

对象头:

Hotspot虚拟机的对象头主要包括两部分数据:Mark Word(标记字段)、Klass Pointer(类型指针)其中Klass Point是是对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例,下面重点说下Mark Word 中存储的数据

“Mark Word”,用于存储对象自身的运行时数据, 如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等等,这部分数据的长度在32位和64位的虚拟机中分别为32个和64个Bits。

但是对象需要存储的运行时数据很多,其实已经超出了
32、64位Bit结构所能记录的限度,所以Mark Word被设计成一个非固定的数据结构以便在极小的空间内存储尽量多的信息,它会根据对象的状态复用自己的存储空间。

如上图所示, synchronized 锁定对象时, 使用的锁标致就是来源于对象头中的后三位,根据不同的锁状态(后面说),做出相应的调整,当一个对象初始创建时,就具备锁的性质,如果被用作锁对象,才会起到作用

实例数据:即创建对象时,存储对象中成员变量,方法等

对齐填充:对象的大小必须是8字节的整数倍(为了内存对齐提高运行速度),如果上面两个区占不到则由这个区域来补充

了解了对象的内存模型,我们可以知道 , 为什么synchronized 可以使用任何一个对象作为锁对象, 而monitorenter / monitorexit 则是synchronized 在 获取执行代码权限时,去监控对应锁对象中内存头上的锁信息,如果发现没有被占用,则获得锁,如果锁被占用,则等待

3. 锁升级

在jdk1.6前,synchronized 之所以重,就是无脑使用重量级锁,导致程序因为频繁的锁 释放获取 ,而变慢,在jdk1.6中,对synchronized 进行优化,锁的状态总共有四种,无锁状态、偏向锁、轻量级锁和重量级锁。随着锁的竞争,锁可以从偏向锁升级到轻量级锁,再升级的重量级锁,但是锁的升级是单向的,也就是说只能从低到高升级,不会出现锁的降级。下图为锁的升级全过程简图:

无锁状态:

synchronized优化的过程和markword息息相关,而markword 则储存在对象中, 任何一个对象在初始后,都可以成为锁,不管他有没有被synchronized 用到, 但是 在jvm 初始化前四秒,对象中是无锁状态,这也是 偏向锁的时延,

因为JVM虚拟机自己有一些默认启动的线程,里面有好多sync代码,这些sync代码启动时就知道肯定会有竞争,如果使用偏向锁,就会造成偏向锁不断的进行锁撤销和锁升级的操作,效率较低。所以在jvm启动过程中,关闭了所有对象的锁

可以使用下面的命令调整延迟时间

-XX:BiasedLockingStartupDelay=0

偏向锁:

这是java对象最基本的锁,除了上述的情况,对象new成功后,默认就是此锁,如果该对象没有被synchronized 使用,则称为 匿名偏向锁,.

经过研究发现,在大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得,因此为了减少同一线程获取锁的代价而引入偏向锁。偏向锁的核心思想是,如果一个线程获得了锁,那么Mark Word 的结构中将存储此线程的ID,当这个线程再次请求锁时,无需再做任何同步操作,即获取锁的过程,这样就省去了大量有关锁申请的操作,从而也就提供程序的性能。所以,对于没有锁竞争的场合,偏向锁有很好的优化效果,毕竟极有可能连续多次是同一个线程申请相同的锁。

偏向锁默认线程没有竞争,如果一旦检测到竞争,则撤销偏向锁,升级为轻量级锁,因为这样场合极有可能每次申请锁的线程都是不相同的,因此这种场合下不应该使用偏向锁,

轻量级锁:

当有线程竞争,升级为轻量级锁,又是cas锁,乐观锁,自旋锁 都可以,这是我心目中最完美的锁,java juc中,cas可以说是基石,充斥大量的cas操作,例如原子操作类

这是基于在大多数情况下,线程持有锁的时间都不会太长,如果直接挂起操作系统层面的线程可能会得不偿失,毕竟操作系统实现线程之间的切换时需要从用户态转换到核心态,这个状态之间的转换需要相对比较长的时间,时间成本相对较高,因此自旋锁会假设在不久将来,当前的线程可以获得锁,因此虚拟机会让当前想要获取锁的线程做几个空循环(这也是称为自旋的原因),如果得到锁,就顺利进入临界区。

但是如果线程执行时间过长,或者竞争非常激烈,导致大量的线程自旋,或者线程自旋次数过多(JDK1.6默认为10次),将会导致占用cpu资源过多,就像前面说的,这些线程没有被挂起,仍然占用cpu,所以依然会导致程序变慢,所以当自旋次数过多,将导致锁升级为重量级锁

自旋的次数可以手动修改(-XX:preBlockSpin),但一般不这么做,在后面JDK中引入了更加聪明的自旋锁,适应自旋锁, 会根据实际情况,去动态调节自旋次数,避免资源的浪费,

重量级锁:

依赖于底层操作系统的Mutex Lock实现,也为操作系统锁,操作系统实现线程之间的切换需要从用户态到内核态的切换,切换成本非常高。但是 没有获取到锁的 线程将挂起,不再占用cpu资源,解决上述 轻量级锁自旋的问题

全过程总结:

  • 每一个java对象 都是一把看不见的锁,当对象创建时,本身就是一个 偏向锁状态,除了jvm刚创建的前几秒 为了 jvm的快速启动,而放弃偏向锁,此时,若对象被synchronized 用作锁对象,若此对象已经为 偏向锁,将仍然为偏向锁的形式,若为无锁的状态 将直接升级为 轻量级锁,

  • 偏向锁在轻度竞争状态下会升级为 轻量级锁,若竞争大 也有可能直接升级为重量级锁,,

  • 轻量级锁 会因为自旋次数过多 防止过度占用cpu资源 将升级为将线程挂起的重量级锁,

放一张网上的图片,更加详细 可以研究研究

posted @ 2020-11-09 17:05  哈哈丶丶  阅读(423)  评论(0编辑  收藏  举报