Java 基础 —— synchronized 关键字详解

一、synchronized 三大特性

在多线程的环境下,多个线程同时访问共享资源会出现一些问题,而 synchronized 关键字则是用来保证线程同步的。

synchronized 关键字可以保证并发编程的三大特性:原子性、可见性、有序性。而 volatile关键字只能保证可见性和有序性,不能保证原子性,也称为是轻量级的 synchronized。

(1)原子性:一个或多个操作要么全部执行成功,要么全部执行失败。synchronized 关键字可以保证只有一个线程拿到锁,访问共享资源
(2)可见性:当一个线程对共享变量进行修改后,其他线程可以立刻看到。可见性是通过Java内存模型中的 “对一个变量 unlock 操作之前,必须要同步到主内存中如果对一个变量进行 lock 操作,则将会清空工作内存中此变量的值,在执行引擎使用此变量前,需要重新从主内存中 load 操作或 assign 操作初始化变量值” 来保证的;
(3)有序性:程序的执行顺序会按照代码的先后顺序执行。有效解决重排序问题,即 “一个unlock操作先行发生(happen-before)于后面对同一个锁的lock操作”。

二、synchronized 原理

1、Java 对象头

synchronized 实际是作用在对象上的,那锁的实现肯定也与对象在内存中的存储有关系。
在 JVM 中,对象在内存中的布局分为三块区域:对象头、实例数据和对齐填充。如下图所示:
Java 基础 —— synchronized 关键字详解_第1张图片

(1)实例数据:存放类的属性数据信息,包括父类的属性信息;
(2)对齐填充:由于虚拟机要求对象起始地址必须是8字节的整数倍。填充数据不是必须存在的,仅仅是为了字节对齐;
(3)对象头:Java 对象头一般占有 2 个机器码(在 32 位虚拟机中,1 个机器码等于 4 字节,也就是 32bit,在 64 位虚拟机中,1 个机器码是 8 个字节,也就是 64bit),但是如果对象是数组类型,则需要 3个 机器码,因为 JVM 虚拟机可以通过 Java 对象的元数据信息确定 Java 对象的大小,但是无法从数组的元数据来确认数组的大小,所以用一块来记录数组长度。

Synchronized 用的锁就是存在 Java 对象头里的,那么什么是 Java 对象头呢?Hotspot 虚拟机的对象头主要包括两部分数据:Mark Word(标记字段)、Class Pointer(类型指针)。其中 Class Pointer 是对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例,Mark Word 用于存储对象自身的运行时数据,它是实现轻量级锁和偏向锁的关键。 Java对象头具体结构描述如下:
Java 基础 —— synchronized 关键字详解_第2张图片
Mark Word 用于存储对象自身的运行时数据,如:哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程 ID、偏向时间戳等。
Java 基础 —— synchronized 关键字详解_第3张图片

2、线程栈帧 Lock Record

在线程进入同步代码块的时候,如果此同步对象没有被锁定,即它的锁标志位是 01,则虚拟机首先在当前线程的栈帧中创建我们称之为“锁记录(Lock Record)”的空间,用于存储锁对象的 Mark Word 的拷贝,官方把这个拷贝称为 Displaced Mark Word。

Lock Record 是线程私有的数据结构,每一个线程都有一个可用 Lock Record 列表,同时还有一个全局的可用列表。每一个锁对象 Mark Word 都会和一个 Lock Record 关联(对象头的 MarkWord 中的 Lock Word 指向Lock Record的起始地址),同时 Lock Record 中有一个 Owner 字段存放拥有该锁的线程的唯一标识(或者object mark word),表示该锁被这个线程占用。如下图所示为Lock Record的内部结构:
Java 基础 —— synchronized 关键字详解_第4张图片

3、监视器(Monitor)——管程

monitor 直译过来是监视器的意思,专业一点叫管程。monitor 是属于编程语言级别的,它的出现是为了解决操作系统级别关于线程同步原语的使用复杂性,类似于语法糖,对复杂操作进行封装。而 Java 则基于 monitor 机制实现了它自己的线程同步机制,就是 synchronized 内置锁
任何一个对象都有一个 Monitor 与之关联,当且一个 Monitor 被持有后,它将处于锁定状态。Synchronized 在 JVM 里的实现都是基于进入和退出 Monitor 对象来实现方法同步和代码块同步,虽然具体实现细节不一样,但是都可以通过成对的 MonitorEnter 和 MonitorExit 指令来实现。

(1)MonitorEnter 指令:插入在同步代码块的开始位置,当代码执行到该指令时,将会尝试获取该对象 Monitor 的所有权,即尝试获得该对象的锁,过程如下:

1)如果 monitor 的进入数为0,则该线程进入 monitor,然后将进入数设置为 1,该线程即为 monitor 的所有者;
2)如果线程已经占有该 monitor,只是重新进入,则进入 monitor 的进入数加 1;
如果其他线程已经占用了 monitor,则该线程进入阻塞状态,直到 monitor 的进入数为 0,再重新尝试获取 monitor 的所有权;

(2)MonitorExit 指令:插入在方法结束处和异常处,JVM 保证每个 MonitorEnter 必须有对应的 MonitorExit;

执行 monitorexit 的线程必须是 objectref 所对应的 monitor 的所有者。指令执行时,monitor 的进入数减 1,如果减 1 后进入数为 0,那线程退出 monitor,不再是这个 monitor 的所有者。其他被这个 monitor 阻塞的线程可以尝试去获取这个 monitor 的所有权。

所有的 Java 对象是天生的锁,每一个 Java 对象都有成为锁的潜质,因为在 Java 的设计中 ,每一个 Java 对象自打娘胎里出来就带了一把看不见的锁。

Monitor 对象可以被每个 Java 对象的对象头 Mark Word 中存储的指针指向, Synchronized 的语义底层是通过一个 monitor 的对象来完成,也是为什么 Java 中任意对象可以作为锁的原因。同时 wait/notify 等方法也依赖于 monitor 对象,这就是为什么只有在同步的块或者方法中才能调用 wait/notify 等方法,否则会抛出 java.lang.IllegalMonitorStateException 的异常的原因。
Java 基础 —— synchronized 关键字详解_第5张图片
1)每个 java 对象在内存中由对象头,实例数据和对齐填充三块区域组成。其中对象头存储了一些增强对象功能的信息,对象头中的 Mark word 记录了锁的相关信息。如果此刻该对象锁升级为重量级锁,那么其中在对象头中存储了指向基于 monitor 锁的指针ptr_to_heavyweight_monitor。这个指针指向的就是我们苦苦寻找的锁。
2)当普通 Java 对象成为锁后,Mark Word 会改变

(1)monitor 的作用

monitor 的作用就是限制同一时刻,只有一个线程能进入 monitor 框定的临界区,达到线程互斥,保护临界区中临界资源的安全,这称为线程同步使得程序线程安全。同时作为同步工具,它也提供了管理进程,线程状态的机制,比如 monitor 能管理因为线程竞争未能第一时间进入临界区的其他线程,并提供适时唤醒的功能。

(2)monitor 的组成

1)monitor 对象

monitor 对象是 monitor 机制的核心,它本质上是 jvm 用 c 语言定义的一个数据类型。对应的数据结构保存了线程同步所需的信息,比如保存了被阻塞的线程的列表,还维护了一个基于mutex的锁,monitor 的线程互斥就是通过 mutex 互斥锁实现的

2)临界区

临界区是被 synchronized 包裹的代码块,可能是个代码块,也可能是个方法。

3)条件变量

条件变量和下方 wait signal 方法的使用有密切关系 。在获取锁进入临界区之后,如果发现条件变量不满足,使用 wait 方法使线程阻塞,条件变量满足后 signal 唤醒被阻塞线程。

tips:当线程被 signal 唤醒之后,不是从 wait 那继续执行的,而是重新 while 循环一次判断条件是否成立。

4)定义在 monitor 对象上的 wait() signal() signalAll() 操作

(3)执行流程

Java 基础 —— synchronized 关键字详解_第6张图片
Java 基础 —— synchronized 关键字详解_第7张图片
1)线程访问同步代码,需要获取 monitor 锁;
2)线程被 Jvm 托管;
3)Jvm 获取充当临界区锁的 Java 对象 obj;
4)根据 Java 对象对象头中 Mark Word 的重量级锁 ptr_to_heavyweight_monitor 指针找到 Monitor
5)将当前线程包装成一个 ObjectWaiter 对象,然后进入 ContentionList 队列头部;
6)如果 owner 是其他线程说明当前 monitor 被占据,则当前线程阻塞。如果没有被其他线程占据,则将 owner 设置为当前线程,将线程从等待队列中删除。
7)当前线程获取 monitor 锁,如果条件变量不满足,则将线程放入 WaitSet 中。当条件满足之后被唤醒,把线程从 WaitSet 转移到 EntrySet 中
8)当前线程临界区执行完毕;
9)Owner 线程会在 unlock 时,将 ContentionList 中的部分线程迁移到 EntryList 中,并指定 EntryList 中的某个线程为 OnDeck 线程(一般是最先进去的那个线程)。Owner 线程并不直接把锁传递给 OnDeck 线程,而是把锁竞争的权利交个 OnDeck,OnDeck 需要重新竞争锁
Java 基础 —— synchronized 关键字详解_第8张图片
Java 基础 —— synchronized 关键字详解_第9张图片

三、synchronized 的使用

synchronized 关键字可以实现什么类型的锁?

(1)悲观锁:synchronized 关键字实现的是悲观锁,每次访问共享资源时都会上锁。
(2)非公平锁:synchronized 关键字实现的是非公平锁,即线程获取锁的顺序并不一定是按照线程阻塞的顺序。
(3)可重入锁:synchronized 关键字实现的是可重入锁,即已经获取锁的线程可以再次获取锁。
(4)独占锁或者排他锁:synchronized 关键字实现的是独占锁,即该锁只能被一个线程所持有,其他线程均被阻塞。

synchronized 的锁粒度

synchronized 的锁可作用于 Java 方法,或者是一个代码块。无论何种用法,所起到的作用仅限于类锁/对象锁。 Java 的 synchronized 锁的是对象,也只锁对象。
synchronized从锁的是谁的维度一共有两种情况:

(1)对象实例作为一把锁:对象锁是基于对堆内存内对象的头部加锁信息,每一个对象锁都是不同的锁。特别的,synchronized(this) 是对 this 所对应的对象加锁。

1)synchronized 修饰 this 的代码块;
2)synchronized 修饰一个对象 xxx 的代码块;
3)synchronized 修饰一个不使用 static 关键字修饰的方法

(2)类作为一把锁:类锁是基于对类对应的 java.lang.Class 加锁信息,锁的是该类的所有实例对象,该类的所有对象都使用同一把类锁

1)synchronized 修饰一个使用了 static 关键字修饰的方法;
2)当 synchronized 修饰一个 xxx.class 的代码块;

synchronized 的用法有四种:

(1)修饰一个代码块: 一个线程正在访问一个对象中的 synchronized(this 或 其他对象) 同步代码块时,其他试图访问该对象(要是同一个对象)的线程将被阻塞。
(2)修饰一个普通方法: 在方法的前面加 synchronized,public synchronized void method(),此方法等于修饰整个方法代码块。
(3)修饰一个静态的方法:public synchronized static void method(),静态方法是属于类的,同样的,synchronized 修饰的静态方法锁定的是这个类的所有对象。
(4)修饰一个类:synchronized(ClassName.class),synchronized 作用于一个类时,是给这个类加锁,该类的所有对象都将加同一把锁。

同步方法和同步代码块都是通过 monitor 锁实现的,区别:

同步方式是通过方法中的 access_flags 中设置 ACC_SYNCHRONIZED 标志来实现;
同步代码块是通过 monitorenter 和 monitorexit 指令来实现;

1、修饰非静态的代码块

public void run(){

	synchronized(获取所的地方){
		被锁住的代码块
	}
}

这个获取锁的地方是什么呢?我们可以这么理解:

前面的 synchronized 原理中讲到,每个 Java 对象的对象头中都存放关于锁的信息,每一个 Java 对象都可以成为一把锁。synchronized() 的参数可以是某个 Java 对象也可以是某个类 xxx.class:如果是 Java 对象,那么这个 Java 对象成为一把锁,如果是类 xxx.class,则这个类的所有对象都是锁而且是同一把锁。这把锁就把 synchronized 修饰的代码块给锁住,获取锁的地方和被锁住的代码块是不必需要有任何关联的。当线程要执行这段代码块时就必须获得锁才可以,未获得锁的线程则被阻塞。

(1)获取锁的地方为 this:对象锁

 class syncTest implements Runnable {static int i = 0;   //共享资源@Override
     public void run() {
         //其他操作.......
         synchronized (this){   //this 表示当前对象实例
             for (int j = 0; j < 10000; j++) {
                 i++;
             }
         }}

this 所代表的意思是该代码块所在类的对象实例。若通过类 syncTest 创建不同对象,则这些对象锁拥有的锁都是各个不同对象,也就是各自不同的锁

(2)获取锁的地方为对象实例:对象锁

public class test{

    public static void main(String[] args) throws InterruptedException {

        Object lock = new Object();		// 任何对象都可以成为锁对象
        
        Thread A = new Thread(new Runnable() {
            @Override
            public void run() {
                synchronized (lock) {		// 加锁
                    System.out.println("A 1");
                    try {
                        lock.wait();
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                    System.out.println("A 2");
                    System.out.println("A 3");
                }
            }
        });
        Thread B = new Thread(new Runnable() {
            @Override
            public void run() {
                synchronized (lock) {		// 加锁,与线程 A 是同一把锁 lock
                    System.out.println("B 1");
                    System.out.println("B 2");
                    lock.notify();
                    System.out.println("B 3");
                }
            }
        });

        A.start();
        B.start();
    }
}

所有线程用的锁都是同一个锁 lock,因此这种方式是多个线程共享了同一把对象锁。

(3)获取锁的地方为 xxx.class:类锁

 class syncTest implements Runnable {static int i = 0;   //共享资源@Override
     public void run() {
         //其他操作.......
         synchronized (syncTest.class){   // 使用syncTest.class,表示class类锁
             for (int j = 0; j < 10000; j++) {
                 i++;
             }
         }}

xxx.class 所代表的意思是某个类,若通过类 syncTest 创建不同对象,则这些对象锁拥有的锁都是类锁,也就是都拥有同样的锁

试看以下代码:

public class AccountingSync implements Runnable{
    //共享资源(临界资源)
    static int i=0;

    /**
     * synchronized 修饰实例方法
     */
    @Override
    public void run() {
    	synchronized(this){
        for(int j=0;j<10000;j++){
            i++;
        }
       }
    }
    
    public static void main(String[] args) throws InterruptedException {
        AccountingSync instance1 = new AccountingSync();
        AccountingSync instance2 = new AccountingSync();
        Thread t1=new Thread(instance1);
        Thread t2=new Thread(instance2);
        t1.start();
        t2.start();
        t1.join();    // 阻塞主线程,让主线程(main函数)必须等待至 t1 线程执行结束再去执行
        t2.join();    // 阻塞主线程,让主线程(main函数)必须等待至 t2 线程执行结束再去执行
        System.out.println(i);    // 必须等 t1 和 t2 线程执行完再执行该行代码,不然输出错误。
    }
}

输出结果是 11006,而非 20000。

示例中虽然使用 synchronized 关键字修饰了,但是 synchronized() 的参数是 this,因此两次 new AccountingSync() 操作建立的是两个不同的对象,也就是说存在两个不同的对象锁,线程 t1 和 t2 使用的是不同的对象锁,所以不能保证线程安全。

正确示例1:

public class AccountingSync implements Runnable{
    //共享资源(临界资源)
    static int i=0;

    /**
     * synchronized 修饰实例方法
     */
    @Override
    public void run() {
    	synchronized(this){
        for(int j=0;j<10000;j++){
            i++;
        }
       }
    }
    
    public static void main(String[] args) throws InterruptedException {
        AccountingSync instance1 = new AccountingSync();
        // AccountingSync instance2 = new AccountingSync();
        Thread t1=new Thread(instance1);
        Thread t2=new Thread(instance1);
        t1.start();
        t2.start();
        t1.join();    // 阻塞主线程,让主线程(main函数)必须等待至 t1 线程执行结束再去执行
        t2.join();    // 阻塞主线程,让主线程(main函数)必须等待至 t2 线程执行结束再去执行
        System.out.println(i);    // 必须等 t1 和 t2 线程执行完再执行该行代码,不然输出错误。
    }
}

上述两个线程的 target 是同一个对象,所以持有同一把锁 this,所以能够实现线程安全。

正确示例2:

public class AccountingSync implements Runnable{
    //共享资源(临界资源)
    static int i=0;

    /**
     * synchronized 修饰实例方法
     */
    @Override
    public void run() {
    	synchronized(AccountingSync.class){
        for(int j=0;j<10000;j++){
            i++;
        }
       }
    }
    
    public static void main(String[] args) throws InterruptedException {
        AccountingSync instance1 = new AccountingSync();
        AccountingSync instance2 = new AccountingSync();
        Thread t1=new Thread(instance1);
        Thread t2=new Thread(instance2);
        t1.start();
        t2.start();
        t1.join();    // 阻塞主线程,让主线程(main函数)必须等待至 t1 线程执行结束再去执行
        t2.join();    // 阻塞主线程,让主线程(main函数)必须等待至 t2 线程执行结束再去执行
        System.out.println(i);    // 必须等 t1 和 t2 线程执行完再执行该行代码,不然输出错误。
    }
}

示例中 synchronized() 的参数是 xxx.class,锁就是 xxx.class 类的所有实例对象,因此两次 new AccountingSync() 操作建立的两个不同的对象共享同一把类锁,线程 t1 和 t2 使用的是同一把类锁,所以能保证线程安全。

3、修饰普通方法:对象锁

public synchronized void staticA(){

}

对象锁即为 method 方法所在的对象

这种方式相当于

public void run(){

	synchronized(this){
		...
	}
}

3、修饰静态方法:类锁

public static synchronized void staticA(){

}

当 synchronized 作用于静态方法,锁就是 method 所在的 class 类对象,即该 Class 的所有实例对象都是同一把锁,所有线程都共用同一把类锁

这种方式相当于

public void run(){

	synchronized(Test.Class){
		...
	}
}

示例:

 class syncTest implements Runnable {private static int i = 0;   //共享资源private static synchronized void add() {
         i++;
     }@Override
     public void run() {
         for (int j = 0; j < 10000; j++) {
             add();
         }
     }public static void main(String[] args) throws Exception {
      ​ ​
         Thread t1 = new Thread(new syncTest());
         Thread t2 = new Thread(new syncTest());​
         t1.start();
         t2.start();
         t1.join();    // 阻塞主线程,让主线程(main函数)必须等待至 t1 线程执行结束再去执行
        t2.join();    // 阻塞主线程,让主线程(main函数)必须等待至 t2 线程执行结束再去执行
         System.out.println(i);    // 必须等 t1 和 t2 线程执行完再执行该行代码,不然输出错误。
     }
 }

虽然 t1 和 t2 使用不同的对象作为锁,但是这些对象锁都是同一把类锁,所以能够实现线程安全。

四、synchronized 的优化

Java 基础 —— synchronized 关键字详解_第10张图片

1、轻量级锁

轻量级锁不是用来替代传统的重量级锁的,而是在没有多线程竞争的情况下,使用轻量级锁能够减少性能消耗,但是当多个线程同时竞争锁时,轻量级锁会膨胀为重量级锁

(1)加锁过程

1)当线程执行代码进入同步块时,若 Mark Word 为无锁状态,虚拟机先在当前线程的栈帧中建立一个名为 Lock Record 的空间,用于存储当前对象的 Mark Word 的拷贝,官方称之为“Dispalced Mark Word”。
Java 基础 —— synchronized 关键字详解_第11张图片 Java 基础 —— synchronized 关键字详解_第12张图片

2)复制对象头中的 Mark Word 到锁记录中,复制成功后,虚拟机将用 CAS 操作将对象的 Mark Word 更新为执行 Lock Record 的指针,并将 Lock Record 里的 owner 指针指向对象的 Mark Word。如果更新成功,则这个线程拥有了这个锁,并将锁标志设为 00,表示处于轻量级锁状态。
Java 基础 —— synchronized 关键字详解_第13张图片 3)如果更新失败,虚拟机会检查对象的 Mark Word 是否指向当前线程的栈帧,如果是则说明当前线程已经拥有这个锁,可进入执行同步代码。否则说明多个线程竞争,轻量级锁就会膨胀为重量级锁,Mark
Word中存储重量级锁(互斥锁)的指针,后面等待锁的线程也要进入阻塞状态。Java 基础 —— synchronized 关键字详解_第14张图片

(2)解锁过程
Java 基础 —— synchronized 关键字详解_第15张图片
Java 基础 —— synchronized 关键字详解_第16张图片

2、锁膨胀

在这里插入图片描述
Java 基础 —— synchronized 关键字详解_第17张图片 Java 基础 —— synchronized 关键字详解_第18张图片 在这里插入图片描述

3、自旋优化

自旋锁(spin lock)与互斥锁(mutex)类似,任一时刻只有一个线程能够获得锁。当一个线程在获取锁的时候,如果锁已经被其它线程获取,那么该线程将循环等待,然后不断的判断锁是否能够被成功获取,直到获取到锁才会退出循环
在获取锁的过程中,线程一直处于活跃状态。因此与 mutex 不同,spinlock 不会导致线程的状态切换(用户态->内核态),一直处于用户态,即线程一直都是 active 的;不会使线程进入阻塞状态,减少了不必要的上下文切换,执行速度快
由于自旋时不释放 CPU,如果持有自旋锁的线程一直不释放自旋锁,那么等待该自旋锁的线程会一直浪费 CPU 时间。因此,自旋锁主要适用于被持有时间短,线程不希望在重新调度上花过多时间的情况。

在这里插入图片描述
Java 基础 —— synchronized 关键字详解_第19张图片
Java 基础 —— synchronized 关键字详解_第20张图片
(1)自旋锁的实现算法

自旋锁的实现算法大多使用 TAS 算法或者 CAS 算法。

(2)注意事项

1) java6 之后自旋锁是自适应的,比如对象刚刚的一次自旋操作成功过,那么认为这次自旋成功的可能性会高,就多自旋几次;反之,就少自旋甚至不自旋,总之,比较智能
2) 自旋会占用 cpu 时间,单核cpu自旋是浪费,多核 cpu 自旋才能发挥优势。 java7 之后不能控制是否开启自旋功能

4、偏向锁

当只有一个线程多次重复抢占锁同一资源时,即使是轻量级锁每次也至少需要两次(加锁、解锁)CAS操作。而此场景经Hotspot统计是比较容易出现的。所以为了减少不必要的资源浪费,偏向锁应运而生。
在这里插入图片描述

(1)偏向锁的使用
(2)优点

1)只需要执行一次 CAS 即可获取锁
2)采用延迟释放锁策略;
3)锁重入时,只需要判断 mark_word.threadId 是否为当前 threadId 即可

(3)缺点

1)总体上只针对第一个线程有效,新线程获取锁时,会导致锁膨胀
2)锁膨胀时,会导致 stop the world (STW);
3)与原生 hashcode() 互斥,导致偏向锁并非适应于所有的 instance。

示例代码:
Java 基础 —— synchronized 关键字详解_第21张图片
上述代码在加轻量级锁时:
Java 基础 —— synchronized 关键字详解_第22张图片
此时会产生多个锁记录,进行多次 CAS 操作。于是偏向锁产生,只需一次 CAS。
Java 基础 —— synchronized 关键字详解_第23张图片
(4)偏向锁的特点
Java 基础 —— synchronized 关键字详解_第24张图片

5、偏向锁的撤销

(1)调用锁对象的 hashcode
在这里插入图片描述
为什么偏向锁调用 hashcode 会撤销偏向锁,而轻量级锁和重量级锁不会?

1)在轻量级锁中,线程栈帧的 Lock Record 中会保存锁对象的 hashcode 和 age 等信息,在解锁时将其还原到锁对象头中;
2)在重量级锁中,Monitor 对象会保存锁对象的 hashcode 和 age 等信息,在解锁时将其还原到锁对象头中;
3)而偏向锁则没有上述机制。

注:
在这里插入图片描述

(2)其他线程使用锁对象
在这里插入图片描述
例如:
Java 基础 —— synchronized 关键字详解_第25张图片
(3)调用 wait/notify

因为 wait/notify 只有重量级锁才拥有,因此无论当前是轻量级锁还是重量级锁,当调用时一律升级为重量级锁。

6、偏向锁的其他操作

(1)批量重偏向(阈值:20次)
Java 基础 —— synchronized 关键字详解_第26张图片

(2)批量撤销(阈值:40次)
在这里插入图片描述

7、锁消除

Java 基础 —— synchronized 关键字详解_第27张图片
通过比较发现,上述两种方式执行效率差不多。原因:

JIT 即时编译器进行优化,对不存在共享的部分进行锁消除

锁消除的设置:
在这里插入图片描述

五、synchronized 四种锁的升级之路

在 Java 语言中,使用 Synchronized 是能够实现线程同步的,即加锁。并且实现的是悲观锁,在操作同步资源的时候直接先加锁。
加锁可以使一段代码在同一时间只有一个线程可以访问,在增加安全性的同时,牺牲掉的是程序的执行性能,所以为了在一定程度上减少获得锁和释放锁带来的性能消耗,在 jdk6 之后便引入了“偏向锁”和“轻量级锁”,所以总共有4种锁状态,级别由低到高依次为:无锁状态、偏向锁状态、轻量级锁状态、重量级锁状态。这几个状态会随着竞争情况逐渐升级。
Java 基础 —— synchronized 关键字详解_第28张图片

注意:锁可以升级但不能降级,目的是为了提高获得锁和释放锁的效率

1、无锁

无锁是指没有对资源进行锁定,所有的线程都能访问并修改同一个资源,但同时只有一个线程能修改成功。

无锁的特点是修改操作会在循环内进行,线程会不断的尝试修改共享资源。如果没有冲突就修改成功并退出,否则就会继续循环尝试。如果有多个线程修改同一个值,必定会有一个线程能修改成功,而其他修改失败的线程会不断重试直到修改成功。

2、偏向锁

偏向锁是指当一段同步代码一直被同一个线程所访问时,即不存在多个线程的竞争时,那么该线程在后续访问时便会自动获得锁,从而降低获取锁带来的消耗,即提高性能

当一个线程访问同步代码块并获取锁时,会在 Mark Word 里存储锁偏向的线程 ID。在线程进入和退出同步块时不再通过 CAS 操作来加锁和解锁,而是检测 Mark Word 里是否存储着指向当前线程的偏向锁。轻量级锁的获取及释放依赖多次 CAS 原子指令,而偏向锁只需要在置换 ThreadID 的时候依赖一次 CAS 原子指令即可。

偏向锁只有遇到其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁,线程是不会主动释放偏向锁的

关于偏向锁的撤销,需要等待全局安全点,即在某个时间点上没有字节码正在执行时,它会先暂停拥有偏向锁的线程,然后判断锁对象是否处于被锁定状态。如果线程不处于活动状态,则将对象头设置成无锁状态,并撤销偏向锁,恢复到无锁(标志位为01)或轻量级锁(标志位为00)的状态。

偏向锁在 JDK 6 及之后版本的 JVM 里是默认启用的。可以通过 JVM 参数关闭偏向锁:-XX:-UseBiasedLocking=false,关闭之后程序默认会进入轻量级锁状态。

3、轻量级锁

轻量级锁是指当锁是偏向锁的时候,却被另外的线程所访问,此时偏向锁就会升级为轻量级锁,其他线程会通过自旋的形式尝试获取锁,线程不会阻塞,从而提高性能

轻量级锁的获取主要由两种情况:

① 当关闭偏向锁功能时;
② 由于多个线程竞争偏向锁导致偏向锁升级为轻量级锁。

在代码进入同步块的时候,如果同步对象锁状态为无锁状态,虚拟机将首先在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的 Mark Word 的拷贝,然后将对象头中的 Mark Word 复制到锁记录中。

拷贝成功后,虚拟机将使用 CAS 操作尝试将对象的 Mark Word 更新为指向 Lock Record 的指针,并将 Lock Record 里的 owner 指针指向对象的 Mark Word。

如果这个更新动作成功了,那么这个线程就拥有了该对象的锁,并且对象 Mark Word 的锁标志位设置为“00”,表示此对象处于轻量级锁定状态。

如果轻量级锁的更新操作失败了,虚拟机首先会检查对象的 Mark Word 是否指向当前线程的栈帧,如果是就说明当前线程已经拥有了这个对象的锁,那就可以直接进入同步块继续执行,否则说明多个线程竞争锁。

轻量级锁的锁膨胀

1)若当前只有一个等待线程,则该线程将通过自旋进行等待。但是当自旋超过一定的次数时,轻量级锁便会升级为重量级锁(锁膨胀)
2)若当一个线程已持有锁,另一个线程在自旋,而此时又有第三个线程来访时,轻量级锁也会升级为重量级锁(锁膨胀)

4、重量级锁

重量级锁是指当有一个线程获取锁之后,其余所有等待获取该锁的线程都会处于阻塞状态

重量级锁通过对象内部的监视器(monitor)实现,而其中 monitor 的本质是依赖于底层操作系统的 Mutex Lock 实现,操作系统实现线程之间的切换需要从用户态切换到内核态,切换成本非常高。
简言之,就是所有的控制权都交给了操作系统,由操作系统来负责线程间的调度和线程的状态变更。而这样会出现频繁地对线程运行状态的切换,线程的挂起和唤醒,从而消耗大量的系统资源,导致性能低下。

自旋

引入自旋这一规则的原因其实也很简单,因为阻塞或唤醒一个 Java 线程需要操作系统切换 CPU 状态来完成,这种状态转换需要耗费处理器时间。如果同步代码块中的内容过于简单,状态转换消耗的时间有可能比用户代码执行的时间还要长。并且在许多场景中,同步资源的锁定时间很短,为了这一小段时间去切换线程,这部分操作的开销其实是得不偿失的。

自旋优点

在物理机器有多个处理器的情况下,当两个或以上的线程同时并行执行时,我们就可以让后面那个请求锁的线程不放弃 CPU 的执行时间,看看持有锁的线程是否很快就会释放锁。而为了让当前线程“稍等一下”,我们需让当前线程进行自旋。如果在自旋完成后前面锁定同步资源的线程已经释放了锁,那么当前线程就可以不必阻塞而是直接获取同步资源,从而避免切换线程的开销

自旋缺点

它不能代替阻塞。自旋等待虽然避免了线程切换的开销,但它要占用处理器时间。如果锁被占用的时间很短,自旋等待的效果就会非常好。反之,如果锁被占用的时间很长,那么自旋的线程只会白浪费处理器资源

自旋等待的时间必须要有一定的限度,如果自旋超过了限定次数(默认是10次,可以使用 -XX:PreBlockSpin 来更改)没有成功获得锁,就应当挂起线程。

自旋锁在 JDK1.4.2 中引入,使用 -XX:+UseSpinning 来开启。JDK 6 中变为默认开启,并且引入了自适应的自旋锁(适应性自旋锁)。

自适应自旋锁意味着自旋的时间(次数)不再固定,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。如果在同一个锁对象上,自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行中,那么虚拟机就会认为这次自旋也是很有可能再次成功,进而它将允许自旋等待持续相对更长的时间。如果对于某个锁,自旋很少成功获得过,那在以后尝试获取这个锁时将可能省略掉自旋过程,直接阻塞线程,避免浪费处理器资源。

总结

(1)偏向锁通过对比 Mark Word 解决加锁问题,避免执行 CAS 操作

(2)轻量级锁是通过用 CAS 操作和自旋来解决加锁问题,避免线程阻塞和唤醒而影响性能

(3)重量级锁是将除了拥有锁的线程以外的线程都阻塞。

你可能感兴趣的:(Java,并发编程,java,多线程,synchronized)