Java 并发编程深度解析:synchronized 关键字的内部原理与应用

引言

在并发编程中,当多个线程访问同一个共享资源时,我们必须考虑如何维护数据的原子性。Java 是通过 synchronized 关键字实现锁功能来做到这点的,synchronized是 JVM 实现的一种内置锁,锁的获取和释放由 JVM 隐式实现。

锁的本质

在这里插入图片描述
如上图所示,多个线程要访问同一个资源。线程就是一段运行的代码,资源就是一个变量、对象、文件等;而锁就是要实现线程对资源访问的控制,保证同一时刻只能有一个线程去访问某一个资源。

从程序的角度看,锁其实就是一个对象,那么这个对象需要完成以下几个事情:

  1. 对象内部有一个标志位,记录自己是否被某个线程占用;
  2. 如果这个对象被某个线程占用,得记录这个线程的 Thread ID
  3. 这个对象需要维护一个 thread id list,记录其他所有阻塞的、等待获取这个锁的线程,在当前线程释放锁后,从这个 thread id list里面取出一个线程唤醒;

基于上述的描述,来学习一下 synchronized 的使用及原理

基本使用

synchronized 关键字可以作用于方法或方法内的局部代码块。

作用于方法

public synchronized void method1() { // code }

作用于局部代码块

public void method2() { 
	Object o = new Object(); 
	synchronized (o) { 
	 //code
	} 
}

假设现在有一个Counter 类,如下

public class Counter {
  private int increasedSum = 0;
  private int decreasedSum = 0;

  public void add(int value) {
    increasedSum += value;
  }

  public void substract(int value) {
    decreasedSum -= value;
  }
}

尽管add 函数和substract函数是线程不安全的,由于它们访问的共享资源不同,所以它们是可以并发执行的。
我们应该如何使用synchronized加锁,既保证类为线程安全的,又保证两个函数可以并发执行呢?

public class Counter {
  private int increasedSum = 0;
  private int decreasedSum = 0;
  
  private Object obj1 = new Object();
  private Object obj2 = new Object();

  public void add(int value) {
    synchronized (obj1) {
      increasedSum += value;
    }
  }

  public void substract(int value) {
    synchronized (obj2) {
      decreasedSum -= value;
    }
  }
}

synchronized 关键字底层使用的锁是Monitor 锁,每个对象实例都有一个 Monitor 锁,Monitor 锁是寄生于对象存在的,Monitor可以和对象一起创建、销毁。

如果我们想使用一个新的 Monitor 锁,只需新创建一个对象即可。

所以为了让add 函数和substract函数之间能并发执行,可以对这两个函数加不同的锁,即分别使用 obj1 上的锁和 obj2 上的锁。

对象锁和类锁

synchronized 修饰普通方法时,锁为当前实例对象;修饰代码块时,锁为括号里的对象,这些都是对象锁

与对象锁相对应的是类锁

public synchronized static void method3() {
      // code
 }

当用 synchronized 修饰静态方法时,会隐式的使用当前类的类锁;
对于类锁而言,synchronized 使用的也是某个对象上的 Monitor 锁,而这个对象比较特殊,是类的 Class 类对象。
Class 类是所有类的抽象,每个类在 JVM 中都有一个 Class 类对象来表示这个类。

锁的字节码

来看下 synchronized 对应的字节码长什么样子。

public class SynTest {

    //关键字在实例方法上,锁为当前实例
    public synchronized void method1() {
        // code
    }

    //关键字在代码块上,锁为括号里面的对象
    public void method2() {
        Object o = new Object();
        synchronized (o) {
            // code
        }
    }
}

通过反编译看下具体字节码的实现,运行以下反编译命令,就可以输出我们想要的字节码:

javac -encoding UTF-8 SynTest.java  //先运行编译class文件命令
javap -v SynTest.class //再通过javap打印出字节文件

method1对应的字节码如下,从字节码中,我们发现,实际上,编译器只不过是在函数的flags中添加了ACC_SYNCHRONIZED标记而已;

JVM 使用ACC_SYNCHRONIZED标记来区分同步方法,当方法调用时,调用指令先检查该方法是否有ACC_SYNCHRONIZED标志,如果设置了该标志,执行线程将先持有 Monitor 对象,然后再执行方法。在该方法运行期间,其它线程将无法获取到该 Mointor 对象,当方法执行完成后,再释放该 Monitor 对象。

 public synchronized void method1();
    descriptor: ()V
    flags: ACC_PUBLIC, ACC_SYNCHRONIZED
    Code:
      stack=0, locals=1, args_size=1
         0: return
      LineNumberTable:
        line 13: 0

method2对应字节码如下,会发现:synchronized 在修饰同步代码块时,是由 monitorentermonitorexit 指令来实现同步的。进入 monitorenter 指令后,线程将持有 Monitor 对象,退出 monitorenter 指令后,线程将释放该 Monitor 对象。

 public void method2();
    descriptor: ()V
    flags: ACC_PUBLIC
    Code:
      stack=2, locals=4, args_size=1
         0: new           #2                  // class java/lang/Object
         3: dup
         4: invokespecial #1                  // Method java/lang/Object."<init>":()V
         7: astore_1
         8: aload_1
         9: dup
        10: astore_2
        11: monitorenter
        12: aload_2
        13: monitorexit
        14: goto          22
        17: astore_3
        18: aload_2
        19: monitorexit
        20: aload_3
        21: athrow
        22: return

从上述实例可以看出,synchronized语句编译为字节码,只是做了一个简单的翻译而已。我们无法通过synchronized对应的字节码了解其底层实现原理,需要继续深挖。

底层实现原理

在Hotspot JVM中,Monitor锁对应的实现类为ObjectMonitor类,而 ObjectMonitor 是由 C++ 的 ObjectMonitor.hpp 文件实现,如下所示:

ObjectMonitor() {
   _header = NULL;
   _count = 0; //记录个数
   _waiters = 0,
   _recursions = 0;
   _object = NULL; //该 Monitor 锁所属的对象
   _owner = NULL;  //获取到该 Monitor 锁的线程
   _WaitSet = NULL; //处于wait状态的线程,会被加入到_WaitSet
   _WaitSetLock = 0 ;
   _Responsible = NULL ;
   _succ = NULL ;
   _cxq = NULL ; //没有获取到锁的线程暂时加入_cxq
   FreeNext = NULL ;
   _EntryList = NULL ; //存储等待被唤醒的线程
   _SpinFreq = 0 ;
   _SpinClock = 0 ;
   OwnerIsThread = 0 ;
}

通过_object成员变量,可以得到 Monitor 锁所属的对象,也可以通过对象查找到对应的 Monitor 锁,对象头中的 Mark Word 字段用来记录对象所对应的 Monitor 锁。
在这里插入图片描述
在这里插入图片描述

Monitor 锁是如何实现加锁、解锁的呢?

竞争锁

多个线程同时请求获取 Monitor 锁时,它们会通过 CAS 来设置ObjectMonitor_owner字段,谁设置成功,谁就获取到了这个 Monitor 锁。

排队等待锁

成功获取到锁的线程去执行代码,没有获取到锁的线程会被放入ObjectMonitor_cxq中等待锁,_cxq是一个单链表,链表节点的定义如下ObjectWaiter类所示。ObjectWaiter类中包含线程的基本信息以及其他一些结构信息,比如_prev指针、_next指针。

class ObjectWaiter : public StackObj {
 public:
  enum TStates { TS_UNDEF, TS_READY, TS_RUN, TS_WAIT, TS_ENTER, TS_CXQ } ;
  enum Sorted  { PREPEND, APPEND, SORTED } ;
  ObjectWaiter * volatile _next;
  ObjectWaiter * volatile _prev;
  Thread*       _thread;
  jlong         _notifier_tid;
  ParkEvent *   _event;
  volatile int  _notified ;
  volatile TStates TState ;
  Sorted        _Sorted ; // List placement disposition
  bool          _active ; // Contention monitoring is enabled
 public:
  ObjectWaiter(Thread* thread);
  void wait_reenter_begin(ObjectMonitor *mon);
  void wait_reenter_end(ObjectMonitor *mon);
};

ObjectWaiter不仅用来表示单链表的节点(_cxq),还用来表示双向链表的节点(_EntryList_WaitSet),当用来表示单链表的节点时,ObjectWaiter中的_prev指针设置为null。

通知排队等待锁的线程去竞争锁

当持有锁的线程释放锁后,它会从_EntryList中取出一个线程,被取出的线程会再次通过 CAS操作去竞争 Monitor 锁;

如果_EntryList中没有线程,就会先将_cxq中的线程搬移到_EntryList中去,然后再从_EntryList中取出线程。

这里面有几个问题,需要说明一下
1、为什么从_EntryList中取出的线程不直接获取锁而是通过CAS操作去竞争锁?

因为此时有可能存在新来的线程(非_EntryList里的线程)也在竞争锁。

2、为什么不直接从_cxq取线程,而是要将_cxq中的线程倒腾到_EntryList中再取呢?

目的是减少多线程环境下链表存取的冲突,_cxq只负责存操作(往链表中添加节点),_EntryList只负责取操作(从链表中删除节点),冲突减少,线程安全性处理就变得简单。

因为多个线程有可能同时竞争锁失败,同时存入_cxq中,所以,我们需要通过CAS操作来保证往_cxq中添加节点操作的线程安全性。
因为只有释放锁的线程才会从_EntryList中取线程,所以,_EntryList的删除节点操作是单线程操作,不存在线程安全问题。

阻塞

没有获取锁的线程会阻塞,并且对应的内核线程不再分配时间片。

Java线程采用1:1线程模型来实现,一个Java线程会对应一个内核线程。应用程序提交给Java线程执行的代码,会一股脑地交给对应的内核线程来执行。内核线程在执行的过程中,如果遇到synchronized关键字,会执行上述的步骤。

如果没有竞争到锁,则内核线程会调用park()函数将自己阻塞,这样CPU就不再分配时间片给它。

取消阻塞

持有锁的线程在释放锁之后,从_EntryList中取出一个线程时,就会调用unpark()函数,取消对应内核线程的阻塞状态,恢复分配时间片,这样才能让它去执行竞争锁的代码。

流程图

在这里插入图片描述

总结

JVM 在 JDK1.6 中引入了分级锁机制来优化 synchronized,当一个线程获取锁时,首先对象锁将成为一个偏向锁,这样做是为了优化同一线程重复获取导致的用户态与内核态的切换问题;其次如果有多个线程竞争锁资源,锁将会升级为轻量级锁,它适用于在短时间内持有锁,且分锁有交替切换的场景;轻量级锁还使用了自旋锁来避免线程用户态与内核态的频繁切换,大大地提高了系统性能;但如果锁竞争太激烈了,那么同步锁将会升级为重量级锁。

关于锁升级的文章,后续将再研究研究…

参考文献
《Java 编程之美》
《Java 性能调优实战》
《Java 并发编程实战》

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值