知识点:Java sychronized 内部锁实现原理

通过阅读本遍你将获取的知识:

并发编程中synchronized关键字的用法

synchronized锁的内部实现及可重入锁的实现

ObjectMonitor内部主要结构与逻辑

锁有那些状态与锁升级逻辑

JDK对synchronized做了那些优化及优化的原因

[上篇](Java 并发编程 - 原子性.md) 中讲解了多线程原子性的问题,通过ActomicInteger原子类可以在多线程编程时保证原子性的操作,确保操作结果正确,但试想如下场景:

A用户与B将计算一个数值a并且缓存在其它地方,另外有两个共享变量b1 b2分别用于统计用户A B的执行次数,A用户计算数值a的表达式为a=b2+aB用户计算数据值a的表达式为a=b1+a

上面场景中用户之间的计算数值的表达式会有交叉的情况存在,A在获取B用户的执行次数值时一直在发生变化,值a也随着计算次数的变化而变化;b1 b2 a的类型可以使用原子类ActomicInteger进行缓存,使用下面的代码模拟用户的操作

//Value类型模拟缓存的情况
public class Value{
  private static int a;

  public static int getA(){return a;}
  public static void setA(int value){this.a=value;}
}
AtomicInteger b1 =new AtomicInteger(0);
AtomicInteger b2=new AtomicInteger(0);
//模拟A
new Thread(() -> {
  int b = b1.incrementAndGet();
  Value.setA(Value.getA()+b);
}).start();
//模拟B
new Thread(() -> {
  int b = b2.incrementAndGet();
  Value.setA(Value.getA()+b);
}).start();

很不幸的是这样做并不能得到正确的结果。尽管原子类自身是线程安全的,但Value类的setget存在竞争条件,导致它们会产生错误的答案。

线程安全的必要条件是无论多线程中的时序或者交替操作,都要保证多线程间对竞争条件操作时是同一原子操作,在上面的例子中需要保证获取值a 计算新值 将新值更新到a这三个操作在同一原子操作中 ;这样才能保证产生结果的正确性。

内部锁 synchronized

Java提供了强制原子性的内置锁机制:synchronized块。一个synchronized分为两部分,第一部分为锁对象,需要为引用类型,第二部分为需要保护的代码块(保证为同一原子操作);synchronized还可以使用于整个方法上,这时锁对象为当前对象本身,静态方法的锁对象为对象的Class实例。

更正上面示例中错误部分将能得到正确结果:

//示例只修改了B的内容,A的内容修改方式相同
new Thread(() -> {
  int b = b2.incrementAndGet();
  synchronized(Value.class){
  	Value.setA(Value.getA()+b);
  }
}).start();

每一个Java对象都可以隐式地扮演一个用于同步的锁角色,这些内置的锁被称为内部锁或者监视器锁。线程执行synchronized块之前会获取锁对象的内部锁再执行代码块,而无论是正常执行完代码块后退出还是出现异常后退出都会将内部锁释放。

内部锁是互斥锁,同一时间只会有一个拥有同一对象的锁,当线程A尝试请求一个被B线程占有的锁时,A线程将被阻塞等待锁直到B释放占有的锁,如果B永远占有着锁,A将一直等待下去。

因为同一时间只会有一个线程能占有锁,锁被占有后其它的线程只能阻塞后等待,这样就保证了各个线程中synchronized代码块的原子性,不会相互干扰。

在上面的示例中加入synchronized代码块后就能保证在同一时间只一个用户能计算值a,从而保证了操作的原子性。

可重入性

一个线程请求其它线程占有的锁时将被阻塞,然而内部锁是可重入的,因此同一线程试图请求已经占有的锁对象会被请求成功。这意味可重入性的锁是基于“每个线程”的,而不是基本“每一次调用”的。

重入锁的实现原理为每一个锁关联一个锁计数器和一个占有锁的线程,当计数器为0时表示当前锁没有线程占用,线程请求锁时Jvm将记录锁的占有线程并将锁计数器加1,同一个线程重复请求已经占有的锁时锁对应的锁计数器递增,在执行完同步代码块后锁计数器递减,当锁计数器为0时线程将释放锁。

重入简化了锁行为的封装,同时也简化了面向对象并发代码的开发,如下示例:

public abstract class A{
  public int demo(){
    synchronized (A.class){
      //do some ...
      return doDemo();
    }
  }
  public abstract int doDemo();
}
public class B extends A{
  @Override
  public int doDemo() {
    synchronized (A.class) {
      return 0;
    }
  }
}

B继承于类A,在A中的demo中使用synchronized代码块保证多线程安全,B实现了方法doDemo后同样也使用了synchronized代码块来保证多线程安全性,如果锁是不能重入将导致死锁,因为在调用demo方法后线程就已经占有了锁,在demo中调用doDemo方法时再次请求锁将被阻塞,能再次获取的条件是等待锁释放,而锁又是被自己占有的,这导致永远也不会释放,最后线程将一直等待。重入帮我们避免了这种死锁。

synchronized锁实现

synchronized有两种形式,一种是写在类的方法上,另一种是使用synchronized代码块。这两种方式的底层实现都是一样的,下面根据字节码来分析:

image-20210601221521471

通过字节码可以看出synchronized代码块是通过monitorenter指令占有锁,然后通过monitorexit释放锁,配合Exception table解决异常后锁的释放。

Jvm中的每个实例都包含对象头 实例数据 对齐填充这三部分,对象头由Mark WordClass Metadata Address组成,其中Mark Word存储对象的hashCode、锁信息等。在JDK6中synchronized锁分为两种状态:无锁与有锁;而在Jdk6之后对synchronized锁进行了优化,新增加了两种状态,共为四种状态:无锁、偏向锁、轻量级锁、重量级锁,申请锁、升级锁、释放锁Jvm都需要操作Mark Word块的数据。

每一个锁都对应一个monitor对象,在HotSpot虚拟机中它由ObjectMonitor实现(C++实现),每一个对象实例都存在一个与之关联的Monitor实例,在ObjectMonitor内部存在两个队列,一个是 _WaitSet,一个是 _EntryList,WaitSet存放状态为wait的线程,EntryList存放状态为block的线程,另外还有锁计数器与owner变量。owner指向当前占有锁的线程。

ObjectMonitor实现逻辑如下:

  1. 锁计数器为0时表示当前为无锁状态,任何一个线程都可以申请占有该锁
  2. 当某一个线程申请占有锁成功后,锁计数器加1 ,owner变量指向占有锁的线程,锁状态变更为偏向锁
  3. 占有锁的线程再次申请锁时(重入锁的情况),先判断锁的是否为当前申请的线程占有,是申请的线程占有时将锁计数器加1,锁状态保持偏向锁
  4. 申请锁的线程不为占有锁的线程时,锁升级为轻量级锁,申请锁时如果存在两个竞争关系则将锁升级为重量级锁,不存在竞争关系时保持轻量级锁
  5. 申请锁时如果没有已经被其它的线程占有了,刚进行EnterList集合中阻塞
  6. 占用锁的线程释放锁后,锁计数减1,如果锁计数为0则彻底释放锁,owner为null,同时通知EnterList中的线程竞争锁

锁升级的过程是不可逆的,方向为:

无锁 —> 偏向锁—>轻量级锁—>重量级锁

ObjectMonitor锁的竞争是不公平的,并不是谁先申请就一定先获得,而是每次都一起竞争,后申请可能会先获得。

synchronized锁优化

从最近的几个版本中可以看Java团队对synchronized获取锁的方式进行了多次的优化,其中最大的一次是在JDK6当中,通过增加状态、增加锁消除、自旋锁、锁粗化等方式给synchronized锁在性能上带来了很大的提升

  • 锁消除
    • 锁消除发生在编译阶段,当使用内部变量作为锁对象时锁将会被消除,因为这样的锁没有意义,并不会存在资源竞争的情况
  • 锁粗化
    • 在for循环中获取锁时可能会进行优化,例如for(int i=0;i<100;i++){synchronized{j+=i;}}这段代码会被优化,将for写在同步块内来减少锁的申请次数;让锁的粒度更粗,这样的优化被称作为锁优化
  • 自旋锁
    • 在锁存在竞争等待时,等待申请锁的线程不会被立即挂起,而是会进入一个自旋申请的阶段,在该阶段内还没有申请成功时再对线程进行挂起,让申请的线程保持CPU的时间片段;这样优化的原因是大多数的同步块执行时间会较短,切换线程将会变得不值得,而占用CPU的时间片段或许可以获取更高的性能。
    • 但自旋锁也会存在缺点,即在竞争激烈时会浪费过多的CPU时间在自旋上;所以应运而生了自适应自旋锁,自适应自旋锁会根据前面的自旋时间与锁占有者的状态来做判断,决定自己的自旋时间,这样就弥补了自旋锁的不足

synchronized锁是并发编程中不可或缺的一部分,理解其内部原理会给平时工作带来很大的帮助,希望这遍文章能有帮助到你的地方。

精彩推荐:
赠书!《阿里巴巴Java开发手册 第二版》

知识点: Java FutureTask 使用详解
Java 线程创建的三种方式
Java 8种基本值类型

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值