深入理解synchronized关键字(一)

简介: 深入理解synchronized关键字(一)

前言

线程安全


在并发编程学习过程中,我们应该都听过“线程安全”这个名称,对于这一概念,我们知道它可以解决并发编程不安全的问题,也有一个简单的印象:“代码在并发环境下,可以安全地被多个线程使用,这就是线程安全“。上述关于“线程安全”的认识大致是对的,我们来看看别人是如何定义“线程安全”的。


《Java并发编程实战(Java Concurrency In Practice) 》 的作者 Brian Goetz 为“线程安全”做出了一个比较恰当的定义: “当多个线程同时访问一个对象时, 如果不用考虑这些线程在运行时环境下的调度和交替执行, 也不需要进行额外的同步, 或者在调用方进行任何其他的协调操作, 调用这个对象的行为都可以获得正确的结果, 那就称这个对象是线程安全的。 ”


synchronized实现线程安全


互斥同步(Mutual Exclusion & Synchronization) 是一种最常见也是最主要的并发正确性保障手段。 同步是指在多个线程并发访问共享数据时, 保证共享数据在同一个时刻只被一条(或者是一些,当使用信号量的时候) 线程使用。 而互斥是实现同步的一种手段, 临界区(Critical Section) 、 互斥量(Mutex) 和信号量(Semaphore) 都是常见的互斥实现方式。


在 Java 里面, 最基本的互斥同步手段就是 synchronized 关键字, 这是一种块结构(Block Structured) 的同步语法。它解决的是多个线程之间访问资源的同步性,synchronized 关键字可以保证被它修饰的方法或者代码块在任意时刻只能有一个线程执行。 在《Java并发编程Bug的源头》一文中介绍的三个问题,synchronized 关键字都可以顺利地应对,即保证了原子性、可见性和有序性,相较于 volatile 关键字功能更加强大,本文将对该关键字进行深入学习。


synchronized实现方式


synchronized 关键字来实现加锁,注意要搞清楚被锁的资源(操作代码块)和锁,锁可以分为三种:


  • 修饰实例方法,作用于当前实例加锁,进入同步代码前要获得当前实例的锁
  • 修饰静态方法,作用于当前类对象加锁,进入同步代码前要获得当前类对象的锁
  • 修饰代码块,指定加锁对象,对给定对象加锁,进入同步代码库前要获得给定对象的锁。

synchronized 关键字加到静态方法和 synchronized(class)代码块上都是给 Class 类上锁。synchronized 关键字加到实例方法上是给对象实例上锁。尽量不要使用 synchronized(String a) ,因为 JVM 中,字符串常量池具有缓存功能


synchronized作用于实例方法


所谓的实例对象锁就是用 synchronized 修饰实例对象中的实例方法,注意是实例方法不包括静态方法,如下


public class SynchronizedAddTest {
  static int count = 0;
  public static void main(String[] args) {
    SynchronizedAddTest obj = new SynchronizedAddTest();
    Thread t1 = new Thread(() -> {
      obj.add();
    }, "A");
    Thread t2 = new Thread(() -> {
      obj.add();
    }, "B");
    t1.start();
    t2.start();
    try {
      t1.join();
      t2.join();
      System.out.println("main线程输出结果为==>" + count);
    } catch (InterruptedException e) {
      e.printStackTrace();
    }
  }
  public synchronized void add() {
    for (int i = 0; i < 100000; i++) {
      count++;
    }
  }
}
复制代码


上述代码在深入学习volatile一文中有过类似案例,不过使用 volatile 关键字是无法保证原子性的,所以最终的结果可能不是 20万,而且每次运行结果都不一样,总是小于 20万。而加了 synchronized 关键字后,一定可以保证结果为 20万。


自增操作是不具备原子性的,它包括读取变量的原始值、进行加1操作、写入工作内存。那么就是说自增操作的三个子操作可能会分割开执行,就有可能导致下面这种情况出现:


假如某个时刻变量 count 的值为10,

线程A对变量进行自增操作,线程A先读取了变量 count 的原始值,然后线程A被阻塞了(可能存在的情况);

然后线程B对变量进行自增操作,线程B也去读取变量 count 的原始值,由于线程A只是对变量 count 进行读取操作,而没有对变量进行修改操作,所以主存中 count 的值未发生改变,此时线程B会直接去主存读取 count 的值,发现 count 的值为10,然后进行加1操作,并把11写入工作内存,最后写入主存。

然后线程A接着进行加1操作,由于已经读取了 count 的值,注意此时在线程A的工作内存中 count 的值仍然为10,所以线程A对 count 进行加1操作后 count 的值为11,然后将11写入工作内存,最后写入主存。

那么两个线程分别进行了一次自增操作后,inc只增加了1。


加了 synchronized 关键字后,上述情况就会有所变化:


假如某个时刻变量 count 的值为10,

线程A对变量进行自增操作,首先要获取实例对象的锁,然后读值,进行加1操作,即使此时线程A被阻塞了,但不会主动释放锁。

那么线程B想要进行自增操作,就无法获取该实例对象的锁,所以就无法进行自增操作,只能等待线程A执行完毕,释放锁后,线程B才可以获取锁,然后进行自增操作。

线程A在释放锁之前是会将更新后的值写入到主存中,所以线程B就可以拿到最新的 count 值。


需要注意的是:如果是一个线程 A 需要访问实例对象 obj1 的 synchronized 方法 f1(当前对象锁是obj1),另一个线程 B 需要访问实例对象 obj2 的 synchronized 方法 f2(当前对象锁是obj2),这样是允许的,因为两个实例对象锁并不同相同,此时如果两个线程操作数据并非共享的,线程安全是有保障的,遗憾的是如果两个线程操作的是共享数据,那么线程安全就有可能无法保证了,如下代码将演示出该现象。


SynchronizedAddTest obj1 = new SynchronizedAddTest();
SynchronizedAddTest obj2 = new SynchronizedAddTest();
Thread t1 = new Thread(() -> {
  obj1.add();
}, "A");
Thread t2 = new Thread(() -> {
  obj2.add();
}, "B");
//输出结果为:
main线程输出结果为==>111538
复制代码


虽然我们使用 synchronized 修饰了 increase 方法,但却 new 了两个不同的实例对象,这也就意味着存在着两个不同的实例对象锁,因此t1和t2都会进入各自的对象锁,也就是说t1和t2线程使用的是不同的锁,因此线程安全是无法保证的。


解决这种困境的的方式是将 synchronized 作用于静态的 add 方法,这样的话,对象锁就当前类对象,由于无论创建多少个实例对象,但对于的类对象拥有只有一个,所有在这样的情况下对象锁就是唯一的。下面我们看看如何使用将 synchronized 作用于静态的 add 方法。


synchronized作用于静态方法


当 synchronized 作用于静态方法时,其锁就是当前类的class对象锁。由于静态成员不专属于任何一个实例对象,是类成员,因此通过class 对象锁可以控制静态成员的并发操作。


public class SynchronizedAddTest {
  static int count = 0;
  public static void main(String[] args) {
    SynchronizedAddTest obj1 = new SynchronizedAddTest();
    SynchronizedAddTest obj2 = new SynchronizedAddTest();
    Thread t1 = new Thread(() -> {
      obj1.add();
    }, "A");
    Thread t2 = new Thread(() -> {
      obj2.add();
    }, "B");
    t1.start();
    t2.start();
    try {
      t1.join();
      t2.join();
      System.out.println("main线程输出结果为==>" + count);
    } catch (InterruptedException e) {
      e.printStackTrace();
    }
  }
  public static synchronized void add() {
    for (int i = 0; i < 100000; i++) {
      count++;
    }
  }
}
//
main线程输出结果为==>200000
复制代码


需要注意的是,如果一个线程A调用一个实例对象的非 static synchronized 方法,而线程B需要调用这个实例对象所属类的静态 synchronized 方法,这是允许的,不会发生互斥现象,因为访问静态 synchronized 方法占用的锁是当前类对象的锁,而访问非静态 synchronized 方法占用的锁是当前实例对象锁。


synchronized同步代码块


除了使用关键字修饰实例方法和静态方法外,还可以使用同步代码块。某些情况下,方法体中只有部分代码存在并发安全隐患,如果直接对整个方法进行同步操作,可能会得不偿失,此时我们可以使用同步代码块的方式对需要同步的代码进行包裹,这样就无需对整个方法进行同步操作了,同步代码块的使用示例如下:


public class SynchronizedAddTest {
  static int count = 0;
  public static void main(String[] args) {
    SynchronizedAddTest obj = new SynchronizedAddTest();
    Thread t1 = new Thread(() -> {
      obj.add();
    }, "A");
    Thread t2 = new Thread(() -> {
      obj.add();
    }, "B");
    t1.start();
    t2.start();
    try {
      t1.join();
      t2.join();
      System.out.println("main线程输出结果为==>" + count);
    } catch (InterruptedException e) {
      e.printStackTrace();
    }
  }
  public void add() {
    synchronized (this) {
      for (int i = 0; i < 100000; i++) {
        count++;
      }
    }
  }
}
//main线程输出结果为200000
复制代码


同步代码块与作用在实例方法上的加锁对象是一样的,都是对当前线程持有的 instance 实例对象进行加锁,多个线程调用同一个实例对象的方法,需要先获取对象锁,一旦某个线程持有该对象锁,其他线程就必须等待。


了解完 synchronized 的基本含义及其使用方式后,下面我们将进一步深入理解 synchronized 的底层实现原理。


synchronized底层原理


在《Happens-Before规则详解》一文中讲解 Happens-Before 规则时,其中有个规则叫做「管程锁定规则」,具体定义为:synchronized 是 Java 对管程的实现,管程中的锁在 Java 里是隐式实现的,隐式加锁、释放锁,对一个锁的解锁 Happens-Before 于后续对这个锁的加锁。


再往深处来说,Java 虚拟机中的同步(synchronization)是基于进入和退出管程(Monitor)对象实现的, 无论是显式同步(有明确的 monitorenter 和 monitorexit 指令,即同步代码块),还是隐式同步都是如此。在 Java 语言中,同步用的最多的地方可能是被 synchronized 修饰的同步方法。同步方法并不是由 monitorenter 和 monitorexit 指令来实现同步的,而是由方法调用指令读取运行时常量池中方法的 ACC_SYNCHRONIZED 标志来隐式实现的,关于这点,稍后详细分析。下面先来了解一个概念 Java对象头,这对深入理解synchronized 实现原理非常关键。


Java对象头与Monitor


synchronized 用的锁是存在 Java 对象头里的。在 JVM 中,对象在内存中的布局分为三块区域:对象头、实例数据和对齐填充。


对象头包括两部分信息:标记字段(Mark Word)和类型指针(Class Metadata Address),如果对象是一个数组,还需要一块用于记录数组长度的数据。


1.jpg


其中 Mark Word 在默认情况下存储着对象的 HashCode、分代年龄、锁标记位等,32位 JVM 的 Mark Word 的默认存储结构如下图所示:


2.jpg


我们可以在 JVM 源码 (hotspot/share/oops/markOop.hpp) 中看到对象头中存储内容的定义


public:
  // Constants
  enum { age_bits                 = 4,
         lock_bits                = 2,
         biased_lock_bits         = 1,
         max_hash_bits            = BitsPerWord - age_bits - lock_bits - biased_lock_bits,
         hash_bits                = max_hash_bits > 31 ? 31 : max_hash_bits,
         cms_bits                 = LP64_ONLY(1) NOT_LP64(0),
         epoch_bits               = 2
  };
复制代码


在该文件中关于标记字段的结构有如下示例:


//  32 bits:
//  --------
//             hash:25 ------------>| age:4    biased_lock:1 lock:2 (normal object)
//             JavaThread*:23 epoch:2 age:4    biased_lock:1 lock:2 (biased object)
//             size:32 ------------------------------------------>| (CMS free block)
//             PromotedObject*:29 ---------->| promo_bits:3 ----->| (CMS promoted object)
//
//  64 bits:
//  --------
//  unused:25 hash:31 -->| unused:1   age:4    biased_lock:1 lock:2 (normal object)
//  JavaThread*:54 epoch:2 unused:1   age:4    biased_lock:1 lock:2 (biased object)
//  PromotedObject*:61 --------------------->| promo_bits:3 ----->| (CMS promoted object)
//  size:64 ----------------------------------------------------->| (CMS free block)
//
//  unused:25 hash:31 -->| cms_free:1 age:4    biased_lock:1 lock:2 (COOPs && normal object)
//  JavaThread*:54 epoch:2 cms_free:1 age:4    biased_lock:1 lock:2 (COOPs && biased object)
//  narrowOop:32 unused:24 cms_free:1 unused:4 promo_bits:3 ----->| (COOPs && CMS promoted object)
//  unused:21 size:35 -->| cms_free:1 unused:7 ------------------>| (COOPs && CMS free block)
复制代码


字段含义如下:


  • hash: 对象的哈希码
  • age: 对象的分代年龄
  • biased_lock : 偏向锁标识位
  • lock: 锁状态标识位
  • JavaThread* : 持有偏向锁的线程 ID
  • epoch: 偏向时间戳


markOop中不同的锁标识位,代表着不同的锁状态:


3.jpg


不同的锁状态,存储着不同的数据:


4.jpg


在 Java 早期版本中,synchronized 属于重量级锁,效率低下,因为监视器锁(monitor)是依赖于底层的操作系统的 Mutex Lock 来实现的,Java 的线程是映射到操作系统的原生线程之上的。重量级锁的锁标识位为10,其中指针指向的是 monitor 对象的起始地址。每个 Java 对象都关联着一个 monitor,对象与其 monitor 之间的关系有存在多种实现方式,如 monitor 可以与对象一起创建销毁,或当线程试图获取对象锁时自动生成,但当一个 monitor 被某个线程持有后,它便处于锁定状态。在 Java 虚拟机(HotSpot)中,monitor 是由ObjectMonitor 实现的,其主要数据结构如下(位于HotSpot虚拟机源码 ObjectMonitor.hpp文件,C++实现的)


// initialize the monitor, exception the semaphore, all other fields
// are simple integers or pointers
ObjectMonitor() {
  _header       = NULL;
  _count        = 0;
  _waiters      = 0,
  _recursions   = 0;    //记录嵌套(递归)加锁的次数,最外层的锁的_recursions属性为0
  _object       = NULL;
  _owner        = NULL; //占用当前锁的线程
  _WaitSet      = NULL; //等待集合,处于wait状态的线程,会被加入到_WaitSet,配合 wait和Notify/notifyALl 使用
  _WaitSetLock  = 0 ;   //保护等待队列,简单的自旋锁
  _Responsible  = NULL ;
  _succ         = NULL ;
  _cxq          = NULL ;
  FreeNext      = NULL ;
  _EntryList    = NULL ;  //阻塞队列,处于等待锁block状态的线程,会被加入到该列表,配合synchronized锁进行使用
  _SpinFreq     = 0 ;
  _SpinClock    = 0 ;
  OwnerIsThread = 0 ;
  _previous_owner_tid = 0;
}
复制代码


ObjectMonitor 中有两个队列,_WaitSet 和 EntryList。


ObjectMonitor 对象中有多个属性,这里我们介绍几个重点的字段。


protected:
  ObjectWaiter * volatile _WaitSet; // LL of threads wait()ing on the monitor
protected:
  ObjectWaiter * volatile _EntryList ;     // Threads blocked on entry or reentry.
protected:                         // protected for jvmtiRawMonitor
  void *  volatile _owner;          // pointer to owning thread OR BasicLock
复制代码


WaitSet 用来保存 ObjectWaiter 对象列表( 每个 wait 状态的线程都会被封装成 ObjectWaiter对象),EntryList 用来保存处于 block 状态的线程封装的 ObjectWaiter对象,owner 指向持有 ObjectMonitor 对象的线程。


这里简单描述一下 synchronized(重量级锁)的加锁和解锁过程:当多个线程同时访问一段同步代码时,首先会进入 _EntryList 集合,当线程获取到对象的 monitor 后,owner 变量会设置为当前线程,同时 monitor 中的计数器 count 加1。若线程调用 wait() 方法,将释放当前持有的 monitor,owner 变量恢复为 null,count 自减1,同时该线程进入 WaitSet集合中等待被唤醒。若当前线程执行完毕也将释放 monitor 复位 count 变量的值,以便其他线程进入获取 monitor。


关于上述逻辑可以在 hotspot\src\share\vm\runtime\objectMonitor.cpp 文件中获取,具体可以阅读 enter(TRAPS) 方法,感兴趣的朋友可以可以阅读一下这篇文章

由此可知,monitor 对象存在于每个 Java 对象的对象头中(存储的指针的指向),synchronized 锁便是通过这种方式获取锁的,这也是为什么 Java 中任意对象可以作为锁的原因。


synchronized代码块底层原理


synchronized 同步语句块的情况

public class SynchronizedDemo {
    public void method(){
        synchronized (this){
            System.out.println("synchronized code");
        }
    }
}
复制代码


通过 JDK 自带的 javap 命令查看 SynchronizedDemo 类的相关字节码信息:首先切换到类的对应目录执行 javac SynchronizedDemo.java 命令生成编译后的 .class 文件,然后执行 javap -verbose SynchronizedDemo.class


5.jpg


从上面我们可以看出:


synchronized 同步语句块的实现使用的是 monitorenter 和 monitorexit 指令,其中 monitorenter 指令指向同步代码块的开始位置,monitorexit 指令则指明同步代码块的结束位置。 当执行 monitorenter 指令时,线程试图获取锁也就是获取 monitor 的持有权。当计数器为0则可以成功获取,获取后将锁计数器设为1也就是加1。相应的在执行 monitorexit 指令后,将锁计数器设为0,表明锁被释放。如果获取对象锁失败,那当前线程就要阻塞等待,直到锁被另外一个线程释放为止。这里提到的锁计数器,即上文提到的 count 变量。另外还有锁重入的情况,当线程获取该对象的锁后,在未释放锁之前,可以直接进行代码调用,不需要等待。具体到代码实现,就是重入时重入计数器会加1,这块逻辑在 enter()方法中有描述。


值得注意的是编译器将会确保无论方法通过何种方式完成,方法中调用过的每条 monitorenter 指令都要执行其对应的 monitorexit 指令,而无论这个方法是正常结束还是异常结束。为了保证在方法异常完成时 monitorenter 和 monitorexit 指令依然可以正确配对执行,编译器会自动产生一个异常处理器,这个异常处理器声明可处理所有的异常,它的目的就是用来执行 monitorexit 指令。从字节码中也可以看出多了一个monitorexit指令,它就是异常结束时被执行的释放monitor 的指令。


synchronized方法底层原理


方法级的同步是隐式,即无需通过字节码指令来控制的,它实现在方法调用和返回操作之中。JVM可以从方法常量池中的方法表结构(method_info Structure) 中的 ACC_SYNCHRONIZED 访问标志区分一个方法是否同步方法。当方法调用时,调用指令将会检查方法的 ACC_SYNCHRONIZED 访问标志是否被设置,如果设置了,执行线程将先持有 monitor , 然后再执行方法,最后再方法完成(无论是正常完成还是非正常完成)时释放 monitor。


public class SynchronizedDemo {
    public synchronized void foo(){
        System.out.println("synchronized method");
    }
}
复制代码

6.jpg


synchronized 修饰的方法并没有 monitorenter 指令和 monitorexit 指令,取得代之的确实是 ACC_SYNCHRONIZED 标识,该标识指明了该方法是一个同步方法,JVM 通过该 ACC_SYNCHRONIZED 访问标志来辨别一个方法是否声明为同步方法,从而执行相应的同步调用。


目录
相关文章
|
安全 Java
【Synchronized关键字】
【Synchronized关键字】
|
3月前
|
安全 Java
synchronized关键字
在Java中,`synchronized`确保多线程安全访问共享资源。应用于实例方法时,锁绑定于对象实例,仅阻止同一对象的其他同步方法访问;应用于静态方法时,锁绑定于整个类,阻止该类所有同步静态方法的同时访问。实例方法锁作用于对象级别,而静态方法锁作用于类级别,后者影响所有对象实例。正确应用可避免并发问题,提升程序稳定性和性能。
|
3月前
|
Java 程序员 开发者
Java并发编程之深入理解synchronized关键字
本文旨在探究Java语言中一个核心且经常被误解的并发控制工具——synchronized关键字。通过分析其内部机制、使用场景和性能考量,我们将揭示这个简单关键字背后隐藏的强大功能和潜在陷阱。文章将引导你重新认识synchronized,并学会如何在实际开发中高效利用它来构建健壮的多线程应用程序。
|
6月前
|
Java
Java并发编程:深入理解Synchronized关键字
【5月更文挑战第27天】Java并发编程是Java开发中不可或缺的一部分,而synchronized关键字则是实现并发控制的重要手段之一。本文将深入探讨synchronized关键字的使用方法、原理以及注意事项,帮助读者更好地理解和应用这一关键字,提高Java并发编程的能力。
|
6月前
|
安全 Java 调度
Java多线程- synchronized关键字总结
Java多线程- synchronized关键字总结
49 0
|
6月前
|
安全 Java 程序员
synchronized关键字与ReentrantLock的区别和应用
synchronized关键字与ReentrantLock的区别和应用
39 0
|
存储 安全 Java
JUC第五讲:关键字synchronized详解
JUC第五讲:关键字synchronized详解
|
安全 Java 编译器
volatile 与 synchronized 关键字的区别?
volatile 与 synchronized 关键字的区别?
54 0
|
存储 安全 Java
synchronized关键字讲解
synchronized关键字讲解
synchronized关键字讲解
|
缓存 Java 编译器
深入理解synchronized关键字
synchronized关键字详解
88 0