Java 并发 (3) -- Synchronized 关键字

1. 简介

  1. synchronized 关键字保证了多个线程之间访问资源的同步性,被它修饰的方法或者代码块在任意时刻只能有一个线程执行

  2. 另外,synchronized 关键字可以保证一个线程的变化(其实主要是指共享数据的变化)被其他线程所看到,也就是保证了可见性,这一点完全可以替代 volatile 的功能

  3. synchronized 关键字可以修饰实例方法 、静态方法和代码块,被修饰的代码块又可以细分出非静态代码块和静态代码块

  4. Java 虚拟机中的同步是基于管程对象(即 monitor 对象)来实现的,无论是显式同步还是隐式同步都是如此;所谓的显式同步就是指有明确的调用 monitorenter 和 monitorexit 指令来实现的同步,这是 synchronized 修饰的同步代码块的底层实现原理;隐式同步是指通过方法调用指令读取运行时常量池中方法的 ACC_SYNCHRONIZED 标志来实现同步,这是 synchronized 修饰的同步方法的底层实现原理
    在这里插入图片描述

  5. 一般而言,synchronized 使用的锁对象是存储在 Java 对象头里的,JVM 中采用 2 个字来存储对象头,如果对象是数组则会分配 3 个字,多出来的 1 个字用来记录数组的长度,Java 对象头的主要结构是由 Mark Word 和 Class Metadata Address 组成;Mark Word 在默认情况下存储着对象的 hashCode 、分代年龄 、锁标记位等等信息,其中锁标志位为 10,且锁状态为重量级锁指的就是 synchronized 的对象锁,其指针指向的是 monitor 对象的起始地址,每个对象都存在着一个 monitor 与之关联,对象与其 monitor 之间的关系又存在多种实现方式,例如 monitor 可以与对象一起创建 、销毁,也可以在线程试图获取对象锁的时候自动生成,但当一个 monitor 被某个线程持有后,它便处于锁定状态,其他线程此时就没办法获取该 monitor

    在 Java 虚拟机中,monitor 是由 ObjectMonitor 实现的,而 ObjectMonitor 是由 C++ 实现的;ObjectMonitor 中有两个队列,_WaitSet 和 _EntryList,他们都是用来保存 ObjectWaiter 对象列表的,每个等待锁的线程都会被封装成 ObjectWaiter 对象, _owner 指向持有 ObjectMonitor 对象的线程

    当多个线程同时访问一段同步代码时,首先会进入 _EntryList 集合,当线程获取到对象的 monitor 后会进入 _owner 区域,把 monitor 中的 owner 变量设置为当前线程持有, 并将 monitor 中的计数器 count 加1,若线程调用 wait() 方法,将释放当前持有的 monitor,owner 变量恢复为 null,count 自减 1,同时该线程进入 _WaitSet 集合中等待被唤醒。若当前线程执行完毕将释放 monitor 并复位变量的值,以便其他线程进入获取 monitor;因为 monitor 对象存在于每个 Java 对象的对象头中,所以 Java 中的任意对象都可以作为锁

  6. 另外,在 Java 早期版本中,synchronized 属于重量级锁,效率低下,因为监视器锁是依赖于底层操作系统的 Mutex Lock 来实现的,Java 的线程是映射到操作系统的原生线程之上的。所以,如果要挂起或者唤醒一个线程,都是需要操作系统帮忙完成的,而操作系统要实现线程之间的切换则需要从用户态转换到内核态,这个状态之间的转换是需要相对比较长的时间的,即时间成本相对较高,这也是为什么早期的 synchronized 效率低下的原因。但庆幸的是,在 JDK 1.6 之后 Java 官方对在 JVM 层面为 synchronized 关键字引入了许多的优化,例如引入 偏向锁 、轻量级锁 、自旋锁 、适应性自旋锁 、锁消除 、锁粗化的技术来减少锁操作的开销

  7. 锁主要存在四种状态,依次是:无锁状态、偏向锁状态、轻量级锁状态、重量级锁状态,它们会随着竞争的激烈而逐渐升级。需要注意的是,锁可以升级但不可以降级,这种策略是为了提高获取锁和释放锁的效率

  8. 偏向锁:它是一种针对加锁操作的优化手段,经过研究发现,在大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得,因此,为了减少同一线程获取锁的代价而引入了偏向锁;偏向锁的核心思想是:如果一个线程获得了锁,那么锁就进入偏向模式,此时 Mark Word 的结构也变为偏向锁结构,当这个线程再次请求锁时,无需再做任何同步操作(即获取锁的过程),这样就省去了大量有关锁申请的操作,从而提高了程序的性能;所以,对于没有锁竞争的场合,偏向锁有很好的优化效果,但是对于锁竞争比较激烈的场合,偏向锁就失效了,因为这样场合极有可能每次申请锁的线程都是不相同的,因此这种场合下不应该使用偏向锁,否则会得不偿失,需要注意的是,偏向锁失败后,并不会立即膨胀为重量级锁,而是先升级为轻量级锁

  9. 轻量级锁:倘若偏向锁失败,虚拟机并不会立即升级为重量级锁,它还会尝试使用一种称为轻量级锁的优化手段;此时 Mark Word 的结构也变为轻量级锁的结构;轻量级锁不是为了代替重量级锁,它的本意是在没有多线程竞争的前提下,减少传统的重量级锁因使用操作系统互斥量而产生的性能消耗,这是因为使用轻量级锁时,不需要申请互斥量;

    轻量级锁能够提升程序性能的依据是 “对绝大部分的锁,在整个同步周期内都不存在竞争”,另外需要注意的是,轻量级锁所适应的场景是线程交替执行同步块的场合,如果存在同一时间访问同一锁的场合,就会导致轻量级锁膨胀为重量级锁

    另外,轻量级锁的加锁和解锁都用到了 CAS 操作;

  10. 自旋锁:轻量级锁失效后,虚拟机为了避免线程真地在操作系统层面被挂起,还会进行一项称为自旋锁的优化手段;因为在大多数情况下,线程持有锁的时间都不会太长,如果直接挂起操作系统层面的线程可能会得不偿失,毕竟操作系统实现线程之间的切换时需要从用户态转换到内核态,这个状态之间的转换需要相对比较长的时间,时间成本相对较高,因此自旋锁会假设在不久将来,当前的线程可以获得锁,因此虚拟机会让当前想要获取锁的线程做几个空循环;需要注意的是:自旋等待不能完全替代阻塞,因为它还是要占用处理器时间的,如果锁被占用的时间短,那么效果当然就很好,反之,自旋等待的时间必须要有限度,如果自旋超过了限定次数任然没有获得锁,就应该挂起线程,自旋次数的默认值是 10 次,用户可以修改 --XX:PreBlockSpin 来更改自旋次数;自旋锁在 JDK1.6 之前其实就已经引入了,不过是默认关闭的,JDK1.6 开始,就改为默认开启了;另外,在 JDK 1.6 中还引入了自适应的自旋锁,自适应自旋锁带来的改进就是:自旋的时间不再固定的了,而是由前一次同一个锁上的自旋时间以及锁的拥有者的状态来决定

  11. 锁消除:是虚拟机另外一种锁的优化,这种优化更彻底。Java 虚拟机在 JIT 编译时(可以简单理解为当某段代码即将第一次被执行时进行编译,又称即时编译),通过对运行上下文的扫描,去除不可能存在共享资源竞争的锁,通过这种方式消除没有必要的锁,可以节省毫无意义的请求锁的时间

  12. 锁粗化(是扩展同步范围):当我们在编写代码的时候,总是推荐将同步块的作用范围限制得尽量小,只在共享数据的实际作用域才进行同步,这样是为了使得需要同步的操作数量尽可能变小,如果存在锁竞争,那等待线程也能尽快拿到锁;大部分情况下,上面的原则都是没有问题的,但是如果一系列的连续操作都对同一个对象反复加锁和解锁,那么会带来很多不必要的性能消耗

    目的是??–》避免一系列的连续操作都是对同一对象进行反复的加锁解锁而所带来的的不必要的性能消耗,锁粗化其实就是为了告诉我们任何事情都要有个度,有些情况下我们反而希望把很多次锁的请求合并成一个请求,以降低短时间内大量锁请求、同步、释放带来的性能损耗

  13. 在 Java 中 synchronized 是基于原子性的内部锁机制,是可重入的,也就是说一个线程得到一个对象锁后再次请求该对象锁,是允许的,这就是 synchronized 的可重入性;由于 synchronized 是基于 monitor 实现的,因此每次重入,monitor 中的计数器都会加 1;另外需要特别注意的一种情况是,当子类继承父类时,子类也是可以通过可重入锁调用父类的同步方法的(调用父类中的方法,如 super.doSomething(), 调用者是子类,具体看他 子类中的同步方法通过可重入锁调用父类中的同步方法

2. 精讲

1. 概念

线程安全是并发编程中的重要关注点,应该注意到的是,造成线程安全问题的主要诱因有两点,一是存在共享数据(也称临界资源),二是存在多条线程共同操作共享数据。因此为了解决这个问题,我们可能需要这样一个方案,当存在多个线程操作共享数据时,需要保证同一时刻有且只有一个线程在操作共享数据,其他线程必须等到该线程处理完数据后再进行,这种方式有个高尚的名称叫互斥锁,即能达到互斥访问目的的锁,也就是说当一个共享数据被当前正在访问的线程加上互斥锁后,在同一个时刻,其他线程只能处于等待的状态,直到当前线程处理完毕释放该锁。

在 Java 中,关键字 synchronized 可以保证在同一个时刻,只有一个线程可以执行某个方法或者某个代码块(主要是对方法或者代码块中存在共享数据的操作),同时我们还应该注意到 synchronized 另外一个重要的作用,synchronized 可保证一个线程的变化(主要是共享数据的变化)被其他线程所看到(保证可见性,完全可以替代 volatile 的功能),这点确实也是很重要的

2. synchronized 的三种应用

1、修饰实例方法,作用于当前实例加锁,进入同步代码前要获得当前实例的锁;

2、修饰静态方法,作用于当前类对象加锁,进入同步代码前要获得当前类对象的锁;

3、修饰代码块,指定加锁对象,对给定对象加锁,进入同步代码库前要获得给定对象的锁

1. 修饰实例方法

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

public class AccountingSync implements Runnable {
 
	// 共享资源(临界资源)
	static int i = 0;
	
	public synchronized void increase(){
		i++;
	}
	
	@Override
	public void run() {
		for(int j = 0; j < 10000; j++){
			increase();
		}
	}
 
	public static void main(String[] args) throws InterruptedException {	
		AccountingSync instance = new AccountingSync();
		Thread t1 = new Thread(instance);
		Thread t2 = new Thread(instance);		
		t1.start();
		t2.start();		
		t1.join();
		t2.join();		
        
		System.out.println(i);   // 输出结果 20000
	}
}

上述代码中,我们开启两个线程,操作同一个共享资源即变量 i ,由于 i++ 操作并不具备原子性,该操作是先读取值,然后写回一个新值,相当于原来的值加上 1,分两步完成,如果第二个线程在第一个线程读取旧值和写回新值期间读取 i 的值,那么第二个线程就会与第一个线程一起看到同一个值,并执行相同值的加 1 操作,这也就造成了线程安全失败,因此对于 increase 方法必须使用 synchronized 修饰,以便保证线程安全。

此时我们应该注意到 synchronized 修饰的是实例方法 increase,在这样的情况下,当前线程的锁便是实例对象 instance,注意 Java 中的线程同步锁可以是任意对象。从代码执行结果来看确实是正确的,倘若我们没有使用 synchronized 关键字,其最终输出结果就很可能小于 20000,这便是 synchronized 关键字的作用。

这里我们还需要意识到,当一个线程正在访问一个对象的 synchronized 实例方法,那么其他线程不能访问该对象的其他 synchronized 方法,毕竟一个对象只有一把锁,当一个线程获取了该对象的锁之后,其他线程无法获取该对象的锁,所以无法访问该对象的其他 synchronized 实例方法,但是其他线程还是可以访问该实例对象的其他非 synchronized 方法,当然如果是一个线程 A 需要访问实例对象 obj1 的 synchronized 方法 f1(当前对象锁是obj1),另一个线程 B 需要访问实例对象 obj2 的 synchronized 方法 f2(当前对象锁是obj2),这样是允许的,因为两个实例对象锁并不同相同,此时如果两个线程操作数据并非共享的,线程安全是有保障的,遗憾的是如果两个线程操作的是共享数据,那么线程安全就有可能无法保证了,如下代码将演示出该现象:

public class AccountingSyncBad implements Runnable {
 
	static int i = 0;
	
	public synchronized void increase(){
		i++;
	}
 
	@Override
	public void run() {
		for(int j = 0; j < 10000; j++){
			increase();
		}
	}
	
	public static void main(String[] args) throws InterruptedException {		
        // new了两个AccountingSyncBad对象
		Thread t1 = new Thread(new AccountingSyncBad());
		Thread t2 = new Thread(new AccountingSyncBad());		
		t1.start();
		t2.start();		
		// join含义:当前线程等待thread线程终止之后才能从thread.join()返回
		t1.join();
		t2.join();
		
		System.out.println(i); //结果输出 18825
	}
}

上述代码与前面不同的是我们同时创建了两个新实例 AccountingSyncBad,然后启动两个不同的线程对共享变量 i 进行操作,但很遗憾,操作结果是 18825 而不是期望结果 20000,因为上述代码犯了严重的错误,虽然我们使用 synchronized 修饰了 increase 方法,但却 new 了两个不同的实例对象,这也就意味着存在着两个不同的实例对象锁,因此 t1 和 t2 都会进入各自的对象锁,也就是说 t1 和 t2 线程使用的是不同的锁,因此线程安全是无法保证的。解决这种困境的的方式是将 synchronized 作用于静态的 increase 方法,这样的话,对象锁就当前类对象,由于无论创建多少个实例对象,但对于拥有的类对象(即 Class 对象)只有一个,所有在这样的情况下对象锁就是唯一的。下面我们看看如何使用将 synchronized 作用于静态的 increase 方法

2. 修饰静态方法

当 synchronized 作用于静态方法时,其锁就是当前类的 Class 对象锁。由于静态成员不专属于任何一个实例对象,是类成员,因此通过class对象锁可以控制静态成员的并发操作。需要注意的是如果一个线程 A 调用一个实例对象的非静态 synchronized 方法,而线程 B 需要调用这个实例对象所属类的静态 synchronized 方法,是允许的,不会发生互斥现象,因为访问静态 synchronized 方法占用的锁是当前类的 Class 对象,而访问非静态 synchronized 方法占用的锁是当前实例对象锁,看如下代码:

public class AccountingSyncClass implements Runnable {
 
	static int i = 0;
	
	// increase()方法是静态方法,锁是当前Class对象,也就是AccountingSyncClass类对应的Class对象
	public static synchronized void increase(){
		i++;
	}
	
	// 非静态方法,访问时锁不一样,所以不会发生互斥
	public synchronized void increase2(){
		i++;
	}
	
	@Override
	public void run() {
		for(int j = 0; j < 10000; j++){
			increase();
		}
	}
	
	public static void main(String[] args) throws InterruptedException {		
		// new两个 AccountingSyncClass实例
		Thread t1 = new Thread(new AccountingSyncClass());
		Thread t2 = new Thread(new AccountingSyncClass());		
		// 启动线程
		t1.start();
		t2.start();		
		t1.join();
		t2.join();
		
		System.out.println(i); // 结果输出 20000
	}
}

由于 synchronized 关键字修饰的是静态 increase 方法,与修饰实例方法不同的是,其锁对象是当前类的 Class 对象。注意代码中的 increase2 方法是实例方法,其对象锁是当前实例对象,如果别的线程调用该方法,将不会产生互斥现象,毕竟锁对象不同,但我们应该意识到这种情况下可能会发现线程安全问题(操作了共享静态变量 i )

3. 修饰代码块

除了使用关键字修饰实例方法和静态方法外,还可以使用同步代码块,在某些情况下,我们编写的方法体可能比较大,同时存在一些比较耗时的操作,而需要同步的代码又只有一小部分,如果直接对整个方法进行同步操作,可能会得不偿失,此时我们可以使用同步代码块的方式对需要同步的代码进行包裹,这样就无需对整个方法进行同步操作了,同步代码块的使用示例如下:

public class AccountingSyncBlock implements Runnable {

    static AccountingSyncBlock instance = new AccountingSyncBlock();

    static int i = 0;

    @Override
    public void run() {
        // 省略其他耗时操作...
        // 使用同步代码块进行同步操作,锁对象为instance
        synchronized (instance) {
            for(int j = 0; j < 10000; j++){
                i++;
            }
        }
    }

    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(instance);
        Thread t2 = new Thread(instance);
        t1.start();
        t2.start();
        t1.join();
        t2.join();

        System.out.println(i); //输出结果 20000
    }
}

从代码看出,将 synchronized 作用于一个给定的实例对象 instance,即当前实例对象就是锁对象,每次当线程进入 synchronized 包裹的代码块时就会要求当前线程持有 instance 实例对象锁,如果当前有其他线程正持有该对象锁,那么新到的线程就必须等待,这样也就保证了每次只有一个线程执行 i++ 操作。

当然除了 instance 作为对象外,我们还可以使用 this 对象(代表当前实例)或者当前类的 Class 对象作为锁,如下代码:

//this:当前实例对象锁,一个类可以存在若干把锁
synchronized(this){
    for(int j=0;j<10000;j++){
        i++;
    }
}
 
// Class对象锁,一个类只存在一把锁
synchronized(AccountingSync.class){
    for(int j=0;j<10000;j++){
        i++;
    }
}

3. synchronized 底层原理

Java 虚拟机中的同步是基于进入和退出管程(Monitor)对象实现, 无论是显式同步(有明确的 monitorenter 和 monitorexit 指令,即同步代码块)还是隐式同步都是如此。

在 Java 语言中,同步用的最多的地方可能是被 synchronized 修饰的同步方法。同步方法并不是由 monitorenter 和 monitorexit 指令来实现同步的,而是由方法调用指令读取运行时常量池中方法的 ACC_SYNCHRONIZED 标志来隐式实现的,关于这点,稍后详细分析。下面先来了解一个概念:Java 对象头,这对深入理解 synchronized 实现原理非常关键

1. Java 对象头与 Monitor

在JVM中,对象在内存中的布局分为三块区域:对象头、实例数据对齐填充。如下:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-mP4Exx2M-1588756907393)(4. Java 并发(2)- synchronized 关键字.assets/20181206141344426.png)]
实例变量:存放类的属性数据信息,包括父类的属性信息,如果是数组的实例部分还包括数组的长度,这部分内存按 4 字节对齐。

填充数据:由于虚拟机要求对象起始地址必须是 8 字节的整数倍。填充数据不是必须存在的,仅仅是为了字节对齐,这点了解即可。

Java 对象头:顶部位置,它是实现 synchronized 锁对象的基础。这点我们重点分析,一般而言,synchronized 使用的锁对象是存储在 Java 对象头里的,JVM 中采用 2 个字来存储对象头(如果对象是数组则会分配3个字,多出来的 1 个字记录的是数组长度),其主要结构是由 Mark Word 和 Class Metadata Address 组成,其结构说明如下表:
在这里插入图片描述
其中 Mark Word 在默认情况下存储着对象的 hashCode 、分代年龄 、锁标记位等,以下是 32 位 JVM 的 Mark Word 默认存储结构:
在这里插入图片描述
由于对象头的信息是与对象自身定义的数据没有关系的额外存储成本,因此考虑到 JVM 的空间效率,Mark Word 被设计成为一个非固定的数据结构,以便存储更多有效的数据,它会根据对象本身的状态复用自己的存储空间,如 32 位 JVM 下,除了上述列出的 Mark Word 默认存储结构外,还有如下可能变化的结构:
在这里插入图片描述
其中轻量级锁和偏向锁是 JDK 1.6 对 synchronized 锁进行优化后新增加的,稍后我们会简要分析。这里我们主要分析一下重量级锁也就是通常说 synchronized 的对象锁,锁标识位为 10,其中指针指向的是 monitor 对象(也称为管程或监视器锁)的起始地址。每个对象都存在着一个 monitor 与之关联,对象与其 monitor 之间的关系又存在多种实现方式,如 monitor 可以与对象一起创建 、销毁,也可以当线程试图获取对象锁时自动生成,但当一个 monitor 被某个线程持有后,它便处于锁定状态。在 Java 虚拟机(HotSpot)中,monitor 是由 ObjectMonitor 实现的,其主要数据结构如下(位于 HotSpot 虚拟机源码 ObjectMonitor.hpp 文件,C++实现的):

ObjectMonitor() {
    _header       = NULL;
    _count        = 0;     //记录个数
    _waiters      = 0,
    _recursions   = 0;
    _object       = NULL;
    _owner        = NULL;
    _WaitSet      = NULL;  //处于wait状态的线程,会被加入到_WaitSet
    _WaitSetLock  = 0 ;
    _Responsible  = NULL ;
    _succ         = NULL ;
    _cxq          = NULL ;
    FreeNext      = NULL ;
    _EntryList    = NULL ; //处于等待锁block状态的线程,会被加入到该列表
    _SpinFreq     = 0 ;
    _SpinClock    = 0 ;
    OwnerIsThread = 0 ;
}

ObjectMonitor 中有两个队列,_WaitSet 和 _EntryList,用来保存 ObjectWaiter 对象列表( 每个等待锁的线程都会被封装成 ObjectWaiter 对象), _owner 指向持有 ObjectMonitor 对象的线程,当多个线程同时访问一段同步代码时,首先会进入 _EntryList 集合,当线程获取到对象的 monitor 后进入 _owner 区域,并把 monitor 中的 owner 变量设置为当前线程持有, monitor 中的计数器 count 加1,若线程调用 wait() 方法,将释放当前持有的 monitor,owner 变量恢复为 null,count 自减 1,同时该线程进入 _WaitSet 集合中等待被唤醒。若当前线程执行完毕将释放 monitor (锁)并复位变量的值,以便其他线程进入获取 monitor(锁)。如下图所示:
在这里插入图片描述
由此看来,monitor 对象存在于每个 Java 对象的对象头中(存储着指针的指向),synchronized 便是通过这种方式获取锁的,也是为什么 Java 中任意对象可以作为锁的原因,同时也是 notify/notifyAll/wait 等方法存在于顶级对象 Object 中的原因(关于这点稍后还会进行分析),下面我们将进一步分析 synchronized 在字节码层面的具体语义实现

2. synchronized 代码块底层原理

现在我们重新定义一个 synchronized 修饰的同步代码块,在代码块中操作共享变量 i,如下:

public class SyncCodeBlock { 
   public int i; 
   public void syncTask(){
       //同步代码块
       synchronized (this){
           i++;
       }
   }
}

编译上述代码并使用 javap 反编译后得到字节码如下(这里省略一部分没有必要的信息):

Classfile/Users/zju/Downloads/Java8_Action/src/main/java/com/zju/concurrencys/SyncCodeBlock.class
  Last modified 2017-6-2; size 426 bytes
  MD5 checksum c80bc322c87b312de760942820b4fed5
  Compiled from "SyncCodeBlock.java"
public class com.zju.concurrencys.SyncCodeBlock
  minor version: 0
  major version: 52
  flags: ACC_PUBLIC, ACC_SUPER
Constant pool:
  //........省略常量池中数据
  //构造函数
  public com.zju.concurrencys.SyncCodeBlock();
    descriptor: ()V
    flags: ACC_PUBLIC
    Code:
      stack=1, locals=1, args_size=1
         0: aload_0
         1: invokespecial #1  // Method java/lang/Object."<init>":()V
         4: return
      LineNumberTable:
        line 7: 0
  //===========主要看看syncTask方法实现================
  public void syncTask();
    descriptor: ()V
    flags: ACC_PUBLIC
    Code:
      stack=3, locals=3, args_size=1
         0: aload_0
         1: dup
         2: astore_1
         3: monitorenter  //注意此处,进入同步方法
         4: aload_0
         5: dup
         6: getfield      #2 // Field i:I
         9: iconst_1
        10: iadd
        11: putfield      #2 // Field i:I
        14: aload_1
        15: monitorexit   //注意此处,退出同步方法
        16: goto          24
        19: astore_2
        20: aload_1
        21: monitorexit //注意此处,退出同步方法
        22: aload_2
        23: athrow
        24: return
      Exception table:
      //省略其他字节码.......
}
SourceFile: "SyncCodeBlock.java"

我们主要关注字节码中的如下代码:

3: monitorenter      // 进入同步方法
// ..........省略其他  
15: monitorexit      // 退出同步方法
16: goto          24
// 省略其他.......
21: monitorexit      // 退出同步方法

从字节码中可知同步语句块的实现使用的是 monitorenter 和 monitorexit 指令,其中 monitorenter 指令指向同步代码块的开始位置,monitorexit 指令则指明同步代码块的结束位置。

当执行 monitorenter 指令时,当前线程将试图获取 objectref(即对象锁) 所对应的 monitor 的持有权,当 objectref 的 monitor 的锁计数器为 0 时,线程可以成功取得 monitor,并将计数器值设置为 1,取锁成功。如果当前线程已经拥有 objectref 的 monitor 的持有权,那它可以重入这个 monitor (关于重入性稍后会分析),重入时计数器的值也会加 1。倘若其他线程已经拥有 objectref 的 monitor 的所有权,那当前线程将被阻塞,直到正在执行线程执行完毕,即 monitorexit 指令被执行,执行线程将释放 monitor(锁) 并设置锁计数器的值为 0 时,其他线程才有机会持有 monitor 。

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

3. synchronized 方法底层原理

方法级的同步是隐式的,即无需通过字节码指令来控制的,它实现在方法调用和返回操作之中。JVM 可以从方法常量池中的方法表结构(method_info Structure) 中的 ACC_SYNCHRONIZED 访问标志区分一个方法是否同步方法。

当方法调用时,调用指令将会检查方法的 ACC_SYNCHRONIZED 访问标志是否被设置,如果设置了,执行线程将先持有 monitor(虚拟机规范中用的是“管程”一词), 然后再执行方法,最后在方法完成(无论是正常完成还是非正常完成)时释放 monitor。在方法执行期间,执行线程持有了 monitor,其他任何线程都无法再获得同一个 monitor。如果一个同步方法执行期间抛出了异常,并且在方法内部无法处理此异常,那这个同步方法所持有的 monitor 将在异常抛到同步方法之外时自动释放。下面我们看看字节码层面如何实现:

public class SyncMethod { 
   public int i; 
   public synchronized void syncTask(){
           i++;
   }
}

使用 javap 反编译后的字节码如下:

Classfile/Users/zju/Downloads/Java8_Action/src/main/java/com/zju/concurrencys/SyncMethod.class
  Last modified 2017-6-2; size 308 bytes
  MD5 checksum f34075a8c059ea65e4cc2fa610e0cd94
  Compiled from "SyncMethod.java"
public class com.zju.concurrencys.SyncMethod
  minor version: 0
  major version: 52
  flags: ACC_PUBLIC, ACC_SUPER
Constant pool; 
   //省略没必要的字节码
  //==================syncTask方法======================
  public synchronized void syncTask();
    descriptor: ()V
    //方法标识ACC_PUBLIC代表public修饰,ACC_SYNCHRONIZED指明该方法为同步方法
    flags: ACC_PUBLIC, ACC_SYNCHRONIZED
    Code:
      stack=3, locals=1, args_size=1
         0: aload_0
         1: dup
         2: getfield      #2                  // Field i:I
         5: iconst_1
         6: iadd
         7: putfield      #2                  // Field i:I
        10: return
      LineNumberTable:
        line 12: 0
        line 13: 10
}
SourceFile: "SyncMethod.java"

从字节码中可以看出,synchronized 修饰的方法并没有 monitorenter 指令和 monitorexit 指令,取得代之的确实是 ACC_SYNCHRONIZED 标识,该标识指明了该方法是一个同步方法。JVM 通过该 ACC_SYNCHRONIZED 访问标志来辨别一个方法是否被声明为同步方法,从而执行相应的同步调用。这便是 synchronized 锁在同步代码块和同步方法上实现的基本原理。

同时我们还必须注意到的是在 Java 早期版本中,synchronized 属于重量级锁,效率低下,因为监视器锁(monitor)是依赖于底层的操作系统的 Mutex Lock (互斥锁)来实现的,而操作系统实现线程之间的切换时需要从用户态转换到内核态,这个状态之间的转换需要相对比较长的时间,时间成本相对较高,这也是为什么早期的 synchronized 效率低的原因。庆幸的是在 JDK 1.6 之后,Java 官方从 JVM 层面对 synchronized 做了较大的优化,所以现在的 synchronized 锁效率也优化得很不错了,JDK 1.6 之后,为了减少获得锁和释放锁所带来的性能消耗,引入了轻量级锁和偏向锁,接下来我们将简单了解一下 Java 官方在 JVM 层面对 synchronized 锁的优化:

4. JDK 1.6 synchronized 的优化

锁的状态总共有四种,无锁状态 、偏向锁 、轻量级锁和重量级锁。随着锁的竞争,锁可以从偏向锁升级到轻量级锁,再升级的重量级锁,但是锁的升级是单向的,也就是说只能从低到高升级,不会出现锁的降级

1. 偏向锁

偏向锁是 JDK 1.6 之后加入的新锁,它是一种针对加锁操作的优化手段,经过研究发现,在大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得,因此,为了减少同一线程获取锁(会涉及到一些CAS操作,耗时)的代价而引入了偏向锁。

偏向锁的核心思想是:如果一个线程获得了锁,那么锁就进入偏向模式,此时 Mark Word 的结构也变为偏向锁结构,当这个线程再次请求锁时,无需再做任何同步操作(即获取锁的过程),这样就省去了大量有关锁申请的操作,从而提高了程序的性能

所以,对于没有锁竞争的场合,偏向锁有很好的优化效果,毕竟极有可能连续多次是同一个线程申请相同的锁。但是对于锁竞争比较激烈的场合,偏向锁就失效了,因为这样场合极有可能每次申请锁的线程都是不相同的,因此这种场合下不应该使用偏向锁,否则会得不偿失,需要注意的是,偏向锁失败后,并不会立即膨胀为重量级锁,而是先升级为轻量级锁

2. 轻量级锁

倘若偏向锁失败,虚拟机并不会立即升级为重量级锁,它还会尝试使用一种称为轻量级锁的优化手段(JDK1.6 之后加入的)。此时 Mark Word 的结构也变为轻量级锁的结构。

轻量级锁能够提升程序性能的依据是“对绝大部分的锁,在整个同步周期内都不存在竞争”,注意这是经验数据。需要了解的是,轻量级锁所适应的场景是线程交替执行同步块的场合,如果存在同一时间访问同一锁的场合,就会导致轻量级锁膨胀为重量级锁

3. 自旋锁

轻量级锁失败后,虚拟机为了避免线程真地在操作系统层面被挂起,还会进行一项称为自旋锁的优化手段。

这是基于在大多数情况下,线程持有锁的时间都不会太长,如果直接挂起操作系统层面的线程可能会得不偿失,毕竟操作系统实现线程之间的切换时需要从用户态转换到内核态,这个状态之间的转换需要相对比较长的时间,时间成本相对较高。因此自旋锁会假设在不久将来,当前的线程可以获得锁,因此虚拟机会让当前想要获取锁的线程做几个空循环(这也是称为自旋的原因)。一般不会太久,可能是 50 个循环或 100 个循环,在经过若干次循环后,如果得到锁,就顺利进入临界区。如果还不能获得锁,那就会将线程在操作系统层面挂起,这就是自旋锁的优化方式,这种方式确实也是可以提升效率的。如果最后还是没办法也就只能升级为重量级锁了

4. 锁消除

消除锁是虚拟机另外一种锁的优化,这种优化更彻底。Java 虚拟机在 JIT 编译时(可以简单理解为当某段代码即将第一次被执行时进行编译,又称即时编译),通过对运行上下文的扫描,去除不可能存在共享资源竞争的锁,通过这种方式消除没有必要的锁,可以节省毫无意义的请求锁的时间。

如下,StringBuffer 的 append 是一个同步方法,但是在 add 方法中的 StringBuffer 属于一个局部变量,并且不会被其他线程所使用,因此 StringBuffer 不可能存在共享资源竞争的情景,JVM会 自动将其锁消除:

public class StringBufferRemoveSync {
    public void add(String str1, String str2) {
        //StringBuffer是线程安全,由于sb只会在add方法中使用,不可能被其他线程引用
        //因此sb属于不可能共享的资源,JVM会自动消除内部的锁
        StringBuffer sb = new StringBuffer();
        sb.append(str1).append(str2);
    }
 
    public static void main(String[] args) {
        StringBufferRemoveSync rmsync = new StringBufferRemoveSync();
        for (int i = 0; i < 100000; i++) {
            rmsync.add("abc", "123");
        }
    }
}

4. synchronized 的一些关键点

1. synchronized 的可重入性

从互斥锁的设计上来说,当一个线程试图操作一个由其他线程持有的对象锁的临界资源时,将会处于阻塞状态,但当一个线程再次请求自己持有对象锁的临界资源时,这种情况属于重入锁,请求将会成功。在 java 中 synchronized 是基于原子性的内部锁机制,是可重入的,因此在一个线程调用 synchronized 方法的同时在其方法体内部调用该对象另一个 synchronized 方法,也就是说一个线程得到一个对象锁后再次请求该对象锁,是允许的,这就是 synchronized 的可重入性。如下:

public class AccountingSync implements Runnable {
 
	static AccountingSync instance = new AccountingSync();
	static int i = 0;
	static int j = 0;
	
	@Override
	public void run() {
		for(int j = 0; j < 10000; j++){
			// this:当前实例对象锁
			synchronized (this) {
				i++;
				increase();
			}
		}
	}
 
	public synchronized void increase(){
		j++;
	}
	
	public static void main(String[] args) throws InterruptedException {	
		Thread t1 = new Thread(instance);
		Thread t2 = new Thread(instance);		
		t1.start();
		t2.start();		
		t1.join();
		t2.join();
		
		System.out.println(i);   // 输出结果20000
	}
}

正如代码所演示的,在获取当前实例对象锁后进入 synchronized 代码块执行同步代码,并在代码块中调用了当前实例对象的另外一个 synchronized 方法。再次请求当前实例锁时,将被允许,进而执行方法体代码,这就是重入锁最直接的体现。

需要特别注意另外一种情况,当子类继承父类时,子类也是可以通过可重入锁调用父类的同步方法。注意由于 synchronized 是基于 monitor 实现的,因此每次重入,monitor 中的计数器仍会加 1

2. 中断与 synchronized

事实上线程的中断操作对于正在等待获取的锁对象的 synchronized 方法或者代码块并不起作用,也就是对于 synchronized 来说,如果一个线程在等待锁,那么结果只有两种,要么它获得这把锁继续执行,要么它就保持等待,即使调用中断线程的方法,也不会生效。演示代码如下:

public class SynchronizedBlocked implements Runnable {

    public synchronized void f(){
        System.out.println("Trying to call f()");
        while(true){  // Never releases lock
            Thread.yield();
        }
    }

    // 在构造器中创建新线程并自动获取对象锁
    public SynchronizedBlocked() {
        // 该线程已持有当前实例锁
        new Thread() {
            public void run() {
                f(); // Lock acquired by this thread
            }
        }.start();
    }

    @Override
    public void run() {
        // 中断判断
        while (true) {
            if (Thread.interrupted()) {
                System.out.println("中断线程!!");
                break;
            } else {
                f();
            }
        }
    }

    public static void main(String[] args) throws InterruptedException {
        SynchronizedBlocked sync = new SynchronizedBlocked();
        Thread t = new Thread(sync);
        // 启动后,调用f()方法,无法获取当前实例锁处于等待状态
        t.start();
        TimeUnit.SECONDS.sleep(1);
        // 中断线程,无法生效
        t.interrupt();
    }
}

//输出结果
Trying to call f()

我们在 SynchronizedBlocked 构造函数中创建一个新线程并启动,调用 f() 获取到当前实例锁,由于 SynchronizedBlocked 自身也是线程,启动后在其 run 方法中也调用了 f(),但由于对象锁被其他线程占用,导致 t 线程只能等待锁,此时我们调用了 t.interrupt(),但并不能中断线程

3. 等待唤醒机制与 synchronized

所谓等待唤醒机制本篇主要指的是 notify/notifyAll 和 wait 方法。在使用这3个方法时,必须处于 synchronized 代码块或者 synchronized 方法中,否则就会抛出 IllegalMonitorStateException 异常,这是因为调用这几个方法前必须拿到当前对象的监视器 monitor 对象,也就是说 notify/notifyAll 和 wait 方法依赖于 monitor 对象,在前面的分析中,我们知道 monitor 存在于对象头的 Mark Word 中(存储 monitor 引用指针),而 synchronized 关键字可以获取 monitor ,这也就是为什么 notify/notifyAll 和 wait 方法必须在 synchronized 代码块或者 synchronized 方法调用的原因

synchronized (obj) {
    obj.wait();
    obj.notify();
    obj.notifyAll();         
}

需要特别理解的一点是,与 sleep 方法不同的是 wait 方法调用完成后,线程将被暂停,但 wait 方法将会释放当前持有的监视器锁(monitor),直到有线程调用 notify/notifyAll 方法后才能继续执行,而 sleep 方法只让线程休眠并不释放锁。同时 notify/notifyAll 方法调用后,并不会马上释放监视器锁,而是在相应的 synchronized(){}/synchronized 方法执行结束后才自动释放锁

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值