java并发编程 3:synchronized与锁(重点)

目录

    • 共享问题
    • synchronized锁及使用
      • synchronized代码块
      • synchronized 修饰成员方法
      • synchronized 修饰静态方法
    • 变量的线程安全分析
      • 成员变量和静态变量是否线程安全
      • 局部变量是否线程安全
      • 常见线程安全类
    • synchronized底层原理
      • Java对象头
      • Monitor管程
      • 字节码看原理
    • 锁状态及转换
      • 四种锁
      • 无锁
      • 轻量级锁
      • 锁膨胀-重量级锁
      • 自旋优化
      • 偏向锁
      • 偏向锁的重偏向和批量撤销

共享问题

所谓的共享问题就是对于多线程来说,可能存在有多个线程共享进程的资源的时候,由于分时系统,时间切片导致的资源问题。

看下面一个案例:

import lombok.extern.slf4j.Slf4j;


@Slf4j
public class ThreadProblem01 {
    static int counter = 0;
    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 5000; i++) {
                counter++;
            }
        }, "t1");

        Thread t2 = new Thread(() -> {
            for (int i = 0; i < 5000; i++) {
                counter--;
            }
        }, "t2");

        t1.start();
        t2.start();
        t1.join();
        t2.join();
        log.info("{}",counter);
    }

}

问题分析

以上的结果可能是正数、负数、零。为什么呢?因为 Java 中对静态变量的自增,自减并不是原子操作,要彻底理解,必须从字节码来进行分析。

例如对于 i++ 而言(i 为静态变量),实际会产生如下的 JVM 字节码指令:

getstatic i // 获取静态变量i的值
iconst_1 // 准备常量1
iadd // 自增
putstatic i // 将修改后的值存入静态变量i

而对应 i-- 也是类似:

getstatic i // 获取静态变量i的值
iconst_1 // 准备常量1
isub // 自减
putstatic i // 将修改后的值存入静态变量i

而 Java 的内存模型如下,完成静态变量的自增,自减需要在主存和工作内存中进行数据交换:

java并发编程 3:synchronized与锁(重点)_第1张图片

如果是单线程以上 8 行代码是顺序执行(不会交错)没有问题。但是由于分时系统,多线程下这 8 行代码可能交错运行。

比如第一个线程获取到i变量的值0后,进行++操作,把变量i改为1了,但是还没有把变量的值写入到静态变量,这时候,如果时间片用完,这个线程阻塞,把时间片分给另一个线程了,另一个线程获取到i变量的值还是从内存里获取,还是0,然后再–操作,结果为-1,然后写到静态变量里,这时候第一个线程又获取时间片了,又把变量改为1了。

这样就导致最终的结果不对。

基于上述问题,有以下两个概念。

1.临界区 Critical Section

一个程序运行多个线程本身是没有问题的问题出在多个线程访问共享资源。多个线程读共享资源其实也没有问题,在多个线程对共享资源读写操作时发生指令交错,就会出现问题。

一段代码块内如果存在对共享资源的多线程读写操作,称这段代码块为临界区

2.竞态条件 Race Condition

多个线程在临界区内执行,由于代码的执行序列不同而导致结果无法预测,称之为发生了竞态条件

synchronized锁及使用

为了避免临界区的竞态条件发生,有多种手段可以达到目的。

  • 阻塞式的解决方案:synchronized,Lock
  • 非阻塞式的解决方案:原子变量

本次课使用阻塞式的解决方案:使用synchronized加锁,来解决上述问题。

synchronized俗称的【对象锁】,它采用互斥的方式让同一时刻至多只有一个线程能持有【对象锁】,其它线程再想获取这个【对象锁】时就会阻塞住。这样就能保证拥有锁的线程可以安全的执行临界区内的代码,不用担心线程上下文切换。

一般语法如下:

synchronized(对象)
  {
   临界区
  }

synchronized代码块

当线程执行到临界区的代码,会尝试获取对象锁,获取到锁,其他线程执行到会阻塞运行。直到第一个线程释放锁,其他线程才可以获取到锁,才能执行临界区代码。

修改我们上面共享问题的案例,如下:

import lombok.extern.slf4j.Slf4j;


@Slf4j
public class SynchronizedTest01 {
    static int counter = 0;
    // 锁需要的对象可以是任意对象,这里我们随便创建一个对象即可
    static final Object ROOM = new Object();
    
    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 5000; i++) {
                synchronized (ROOM) {
                    counter++;
                }
            }
        }, "t1");
        Thread t2 = new Thread(() -> {
            for (int i = 0; i < 5000; i++) {
                synchronized (ROOM) {
                    counter--;
                }
            }
        }, "t2");
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        log.info("{}",counter);
    }
}

synchronized 实际是用对象锁保证了临界区内代码的原子性,临界区内的代码对外是不可分割的,不会被线程切换所打断。

如果以面向对象来改进上面的代码,还可以写成如下:

import lombok.extern.slf4j.Slf4j;

class Room {
    int value = 0;
    public void increment() {
        synchronized (this) {
            value++;
        }
    }
    public void decrement() {
        synchronized (this) {
            value--;
        }
    }
    public int get() {
        synchronized (this) {
            return value;
        }
    }
}

@Slf4j
public class SynchronizedTest02 {
    public static void main(String[] args) throws InterruptedException {
        Room room = new Room();
        Thread t1 = new Thread(() -> {
            for (int j = 0; j < 5000; j++) {
                room.increment();
            }
        }, "t1");
        Thread t2 = new Thread(() -> {
            for (int j = 0; j < 5000; j++) {
                room.decrement();
            }
        }, "t2");
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        log.info("count: {}" , room.get());
    }
}

注意不加 synchronzied 的方法不需要获取锁,不会受到锁的阻塞。

synchronized 修饰成员方法

synchronized可以加在成员方法上,则相当于锁住了this对象。

修改上面Room类的代码如下:

class Room {
    int value = 0;
    public synchronized void increment() {
        value++;
    }
    public synchronized void decrement() {
        value--;
    }
    public synchronized int get() {
        return value;
    }
}


synchronized 修饰静态方法

synchronized也可以加在static方法上,则相当于锁住了class对象。

class Test{
   public synchronized static void test() {
   }
}
等价于
class Test{
   public static void test() {
       synchronized(Test.class) {

       }
   }
}

变量的线程安全分析

成员变量和静态变量是否线程安全

  • 如果它们没有共享,则线程安全

  • 如果它们被共享了,根据它们的状态是否能够改变,又分两种情况:

    如果只有读操作,则线程安全;如果有读写操作,则这段代码是临界区,需要考虑线程安全。

局部变量是否线程安全

  • 局部变量是线程安全的

  • 但局部变量引用的对象则未必:

    如果该对象没有逃离方法的作用访问,它是线程安全的;如果该对象逃离方法的作用范围,需要考虑线程安全。

如下:

public static void test1() {
   int i = 10;
   i++; 
}

操作i变量时,每个线程有自己栈帧,多个线程的i不会存在线程安全问题。

但是局部变量的引用稍有不同

import java.util.ArrayList;


public class ThreadUnsafe {

    ArrayList<String> list = new ArrayList<>();

    public void method1(int loopNumber) {
        for (int i = 0; i < loopNumber; i++) {
            // { 临界区, 会产生竞态条件
            method2();
            method3();
            // } 临界区
        }
    }
    private void method2() {
        list.add("1");
    }
    private void method3() {
        list.remove(0);
    }

    static final int THREAD_NUMBER = 2;
    static final int LOOP_NUMBER = 200;
    public static void main(String[] args) {
        ThreadUnsafe test = new ThreadUnsafe();
        for (int i = 0; i < THREAD_NUMBER; i++) {
            new Thread(() -> {
                test.method1(LOOP_NUMBER);
            }, "Thread" + i).start();
        }
    }
}

无论哪个线程中的 method2 引用的都是同一个对象中的 list 成员变量,可能两个线程同时add时,只add进一个。method3 与 method2 相同。

下面将 list 修改为局部变量:

import java.util.ArrayList;


public class Threadsafe {

    public void method1(int loopNumber) {
        ArrayList<String> list = new ArrayList<>();
        for (int i = 0; i < loopNumber; i++) {
            // { 临界区, 会产生竞态条件
            method2(list);
            method3(list);
            // } 临界区
        }
    }

    private void method2(ArrayList<String> list) {
        list.add("1");
    }

    private void method3(ArrayList<String> list) {
        list.remove(0);
    }

    static final int THREAD_NUMBER = 2;
    static final int LOOP_NUMBER = 200;
    public static void main(String[] args) {
        Threadsafe test = new Threadsafe();
        for (int i = 0; i < THREAD_NUMBER; i++) {
            new Thread(() -> {
                test.method1(LOOP_NUMBER);
            }, "Thread" + i).start();
        }
    }
}

list 是局部变量,每个线程调用时会创建其不同实例,没有共享;而 method2 的参数是从 method1 中传递过来的,与 method1 中引用同一个对象。不会存在线程安全问题。

方法访问修饰符带来的思考:

如果把 method2 和 method3 的方法修改为 public 会不会存在线程安全问题?

  • 情况1:有其它线程调用 method2 和 method3
  • 情况2:在 情况1 的基础上,为 ThreadSafe 类添加子类,子类覆盖 method2 或 method3 方法

如下ThreadSafeSubClass继承可我们的ThreadSafe,并且重写了方法method3,改为了public修饰符:

import java.util.ArrayList;


public class ThreadSafe {

    public void method1(int loopNumber) {
        ArrayList<String> list = new ArrayList<>();
        for (int i = 0; i < loopNumber; i++) {
            // { 临界区, 会产生竞态条件
            method2(list);
            method3(list);
            // } 临界区
        }
    }

    public void method2(ArrayList<String> list) {
        list.add("1");
    }

    public void method3(ArrayList<String> list) {
        list.remove(0);
    }

    static final int THREAD_NUMBER = 2;
    static final int LOOP_NUMBER = 200;
    public static void main(String[] args) {
        ThreadSafe test = new ThreadSafe();
        for (int i = 0; i < THREAD_NUMBER; i++) {
            new Thread(() -> {
                test.method1(LOOP_NUMBER);
            }, "Thread" + i).start();
        }
    }
}

class ThreadSafeSubClass extends ThreadSafe{
    @Override
    public void method3(ArrayList<String> list) {
        new Thread(() -> {
            list.remove(0);
        }).start();
    }
}

上面也是有线程安全问题的。因为上面的ThreadSafe的method1里的list传到method3里,但是ThreadSafeSubClass的对象的method3新开了一个线程,里面也操作了list,那么list可能同时被两个method3修改。这就是局部局部变量的引用暴漏给了其他线程造成线程不安全问题。

常见线程安全类

  • String
  • Integer
  • StringBuffer
  • Random
  • Vector
  • Hashtable
  • java.util.concurrent 包下的类

String、Integer 等都是不可变类,因为其内部的状态不可以改变,因此它们的方法都是线程安全的。

这里说它们是线程安全的是指,多个线程调用它们同一个实例的某个方法时,是线程安全的。

它们的每个方法是原子的,但注意它们多个方法的组合不是原子的。

Hashtable table = new Hashtable();
// 线程1,线程2
if( table.get("key") == null) {
 		table.put("key", value);
}

上面table对象是线程安全的,但是上面的代码同时调用了get和put方法,就不是线程安全的了。

synchronized底层原理

Java对象头

在java中,一般我们的对象在内存中存储的布局可以分为三块区域:

  • 对象头(Header):主要由mark wordclass word组成。
  • 实例数据(Instance Data):这部分主要是存放类的数据信息,父类的信息;
  • 对齐填充(Padding):由于虚拟机要求对象起始地址必须是8字节的整数倍,填充数据不是必须存在的,仅仅是为了字节对齐。

其中对象头主要包含mark word 和 class word(主要用来标记对象属于哪个类):

java并发编程 3:synchronized与锁(重点)_第2张图片

但是对于数组对象还要多32字节表示字节长度:

java并发编程 3:synchronized与锁(重点)_第3张图片

其中最复杂的是MarkWord

Mark Word用于存储对象自身的运行时数据, 如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等等。

在64位的虚拟机中中,MarkWord的信息如下:

java并发编程 3:synchronized与锁(重点)_第4张图片

在32位的虚拟机中中,MarkWord的信息如下:

java并发编程 3:synchronized与锁(重点)_第5张图片

这里先了解一下大概,MarkWord的组成和具体各种锁有很大关系。各种锁后面还会学。

Monitor管程

Monitor 被翻译为监视器管程。Monitor是由操作系统提供。

每个 Java 对象都可以关联一个 Monitor 对象,如果使用 synchronized 给对象上锁(重量级锁)之后,该对象头的Mark Word 中就被设置指向 Monitor 对象的指针。

Monitor 结构主要有三个:

  • owner:存储目前拥有这个锁的对象的线程
  • EntryList:存储想要获取锁,但是正在排队的线程
  • WaitSet:存储以前获取到锁,但是处于wating状态的线程

其原理如下图:

java并发编程 3:synchronized与锁(重点)_第6张图片

Monitor的使用过程如下:

  1. 刚开始 Monitor 中 Owner 为 null
  2. 当 Thread-2 执行 synchronized(obj) 就会将 Monitor 的所有者 Owner 置为 Thread-2,Monitor中只能有一个 Owner
  3. 在 Thread-2 上锁的过程中,如果 Thread-3,Thread-4,Thread-5 也来执行 synchronized(obj),就会进入EntryList, 状态为BLOCKED
  4. Thread-2 执行完同步代码块的内容,然后唤醒 EntryList 中等待的线程来竞争锁,竞争的时是非公平的
  5. 图中 WaitSet 中的 Thread-0,Thread-1 是之前获得过锁,但条件不满足进入 WAITING 状态的线程,后面讲wait-notify 时会分析

注意:

  • synchronized 必须是进入同一个对象的 monitor 才有上述的效果
  • 不加 synchronized 的对象不会关联监视器,不遵从以上规则

字节码看原理

下面分析下加了synchronized锁的代码的字节码有何变化。

以下面的同步代码块为例:

static final Object lock = new Object();
static int counter = 0;

public static void main(String[] args) {
   synchronized (lock) {
   		counter++;
   }
}

对应的字节码为:

public static void main(java.lang.String[]);
    descriptor: ([Ljava/lang/String;)V
    flags: ACC_PUBLIC, ACC_STATIC
 Code:
     stack=2, locals=3, args_size=1
         0: getstatic #2 // <- lock引用 (synchronized开始)
         3: dup
         4: astore_1 // lock引用 -> slot 1
         5: monitorenter // 将 lock对象 MarkWord 置为 Monitor 指针
         6: getstatic #3 // <- i
         9: iconst_1 // 准备常数 1
         10: iadd // +1
         11: putstatic #3 // -> i
         14: aload_1 // <- lock引用
         15: monitorexit // 将 lock对象 MarkWord 重置, 唤醒 EntryList
         16: goto 24
         19: astore_2 // e -> slot 2 
         20: aload_1 // <- lock引用
         21: monitorexit // 将 lock对象 MarkWord 重置, 唤醒 EntryList
         22: aload_2 // <- slot 2 (e)
         23: athrow // throw e
         24: return
     Exception table:
         from to target type
         	6 	16 	19 		any
         	19 	22 	19 		any
     LineNumberTable:
         line 8: 0
         line 9: 6
         line 10: 14
         line 11: 24
     LocalVariableTable:
         Start Length Slot Name Signature
         0 25 0 args [Ljava/lang/String;
     StackMapTable: number_of_entries = 2
         frame_type = 255 /* full_frame */
         offset_delta = 19
         locals = [ class "[Ljava/lang/String;", class java/lang/Object ]
         stack = [ class java/lang/Throwable ]
         frame_type = 250 /* chop */
         offset_delta = 4

其中重点看一下上面有注释的部分,最重要的是monitorentermonitorexit字节码。

1、monitorenter:如果当前monitor的进入数为0时,线程就会进入monitor,并且把进入数+1,那么该线程就是monitor的拥有者(owner)。

2、如果该线程已经是monitor的拥有者,又重新进入,就会把进入数再次+1。也就是可重入的。

3、monitorexit,执行monitorexit的线程必须是monitor的拥有者,指令执行后,monitor的进入数减1,如果减1后进入数为0,则该线程会退出monitor。其他被阻塞的线程就可以尝试去获取monitor的所有权。

monitorexit指令出现了两次,第1次为同步正常退出释放锁;第2次为发生异步退出释放锁;

总的来说,synchronized的底层原理是通过monitor对象来完成的。

如果是同步的成员方法,代码如下:

public synchronized void hello(){
    System.out.println("hello world");
}

其字节码如下:

java并发编程 3:synchronized与锁(重点)_第7张图片

可以看到多了一个标志位ACC_SYNCHRONIZED,作用就是一旦执行到这个方法时,就会先判断是否有标志位,如果有这个标志位,就会先尝试获取monitor,获取成功才能执行方法,方法执行完成后再释放monitor。在方法执行期间,其他线程都无法获取同一个monitor。归根结底还是对monitor对象的争夺,只是同步方法是一种隐式的方式来实现。

锁状态及转换

补充:下面的知识中涉及了大量的CAS操作,后面会详细讲解,这里先理解为一个“比较并设置”的步骤,需要一定的时间,能保证整个步骤的原子性。

四种锁

前面讲的synchronized上锁是重量级锁。只是上锁的一种,其实在底层还有很多锁的种类,来满足各种需求:

  • 无锁:不锁住资源,多个线程只有一个能修改资源成功,其他线程会重试;
  • 偏向锁:同一个线程获取同步资源时,也就是没有别得线程竞争时,去掉所有同步操作,相当于没锁;
  • 轻量级锁:多个线程抢夺同步资源时,没有获得锁的线程使用CAS自旋等待锁的释放;
  • 重量级锁:多个线程抢夺同步资源时,使用操作系统的互斥量进行同步,没有获得锁的线程阻塞等待唤醒;

结合上面的MarkWord的信息,可总结四种锁状态信息以及特点如下:

锁状态 存储内容 锁标志位
无锁 对象的hashCode、分代年龄、是否偏向锁(0) 01
偏向锁 线程ID、偏向时间戳、对象分代年龄、是否偏向锁(1) 01
轻量级锁 指向占中锁记录的指针 00
重量级锁 指向重量级锁(monitor)的指针 10

其中锁升级的过程是:无锁 -> 偏向锁 -> 轻量级锁 -> 重量级锁。

下面一一来看下各个状态的锁如何产生及转换。

无锁

无锁也就是没有加任何锁。

synchronized锁的object对象头部markword区域,参考上面java对象头存储的信息图,最开始锁状态标志位就是0 01,第一个0代表不是偏向锁。

轻量级锁

轻量级锁的使用场景:如果一个对象虽然有多线程要加锁,但加锁的时间是错开的(也就是没有竞争),那么可以使用轻量级锁来优化。

轻量级锁对使用者是透明的,即语法仍然是 synchronized。也就是说使用者不用做任何事,直接使用synchronized,会优先使用轻量级锁,如果使用失败,再使用重量级锁。

假设有两个方法同步块,利用同一个对象加锁,如下:

static final Object obj = new Object();
public static void method1() {
    synchronized( obj ) {
        // 同步块 A
        method2();
    }
 }
public static void method2() {
    synchronized( obj ) {
     		// 同步块 B
    }
}

其上锁解锁过程如下:

  1. 创建锁记录(Lock Record)对象,每个线程的栈帧都会包含一个锁记录的结构(Lock Record),内部可以存储锁定对象的引用(左图最下面黄色块)和对象的Mark Word(左图最上面黄色块)
    java并发编程 3:synchronized与锁(重点)_第8张图片

  2. 让锁记录中 Object reference 指向锁对象,并尝试用 cas 替换 Object 的 Mark Word,将 Mark Word 的值存入锁记录

java并发编程 3:synchronized与锁(重点)_第9张图片

  1. 如果 cas 替换成功,对象头中存储了锁记录地址和状态 00 ,表示由该线程给对象加锁,这时图示如下
    java并发编程 3:synchronized与锁(重点)_第10张图片

  2. 如果 cas 失败,有两种情况

  • 如果是其它线程已经持有了该 Object 的轻量级锁,这时表明有竞争,进入锁膨胀过程
  • 如果是自己执行了 synchronized 锁重入,那么再添加一条 Lock Record (mark word位置数据为null)作为重入的计数,如下
    java并发编程 3:synchronized与锁(重点)_第11张图片
  1. 当退出 synchronized 代码块(解锁时)如果有取值为 null 的锁记录,表示有重入,这时重置锁记录,表示重入计数减一

java并发编程 3:synchronized与锁(重点)_第12张图片

  1. 当退出 synchronized 代码块(解锁时)锁记录的值不为 null,这时使用 cas 将 Mark Word 的值恢复给对象头
  • 成功,则解锁成功
  • 失败,说明轻量级锁进行了锁膨胀或已经升级为重量级锁,进入重量级锁解锁流程

锁膨胀-重量级锁

如果在尝试加轻量级锁的过程中,CAS 操作无法成功,这时一种情况就是有其它线程为此对象加上了轻量级锁(有竞争),这时需要进行锁膨胀,将轻量级锁变为重量级锁。

static Object obj = new Object();
public static void method1() {
   synchronized( obj ) {
   		// 同步块
   }
}
  1. 当 Thread-1 进行轻量级加锁时,Thread-0 已经对该对象加了轻量级锁,加锁失败

java并发编程 3:synchronized与锁(重点)_第13张图片

  1. Thread-1 加轻量级锁失败,即锁膨胀流程
  • 即为 Object 对象申请 Monitor 锁,让 Object 的Mark Word指向重量级锁地址
  • 然后自己进入 Monitor 的 EntryList,状态为 BLOCKED

java并发编程 3:synchronized与锁(重点)_第14张图片

  1. 当 Thread-0 退出同步块解锁时,使用 cas 将 Mark Word 的值恢复给对象头,失败。这时会进入重量级解锁流程,即按照 Monitor 地址找到 Monitor 对象,设置 Owner 为 null,唤醒 EntryList 中 BLOCKED的线程。

自旋优化

重量级锁竞争的时候,还可以使用自旋来进行优化(一般用循环来实现):即当前线程会自旋一段时间,如果当前线程自旋成功(即这时候持锁线程已经退出了同步块,释放了锁),这时当前线程就可以获取锁,从而避免被阻塞。

注意:

  • 自旋会占用 CPU 时间,单核 CPU 自旋就是浪费,多核 CPU 自旋才能发挥优势。
  • 在 Java 6 之后自旋锁是自适应的,比如对象刚刚的一次自旋操作成功过,那么认为这次自旋成功的可能性会高,就多自旋几次;反之,就少自旋甚至不自旋,总之,比较智能。
  • 自旋也不是一直进行下去的,如果自旋到一定程度(和JVM、操作系统相关),依然没有获取到锁,称为自旋失败,那么这个线程会阻塞。同时这个锁就会升级成重量级锁
  • Java 7 之后不能控制是否开启自旋功能

偏向锁

轻量级锁有个缺点:在没有竞争时(就一个线程使用锁),每次重入(同一个线程内多次获取同一个锁)仍然需要执行 CAS 操作。这样是非常耗时的操作。

如下示例:在m1所在线程的同步块里又调用了m2方法,有进入m2的同步块,同时调用m3,又进入m3的同步块

static final Object obj = new Object();
public static void m1() {
   synchronized( obj ) {
       // 同步块 A
       m2();
   }
}
public static void m2() {
   synchronized( obj ) {
     // 同步块 B
     m3();
   }
}
public static void m3() {
   synchronized( obj ) { 
     	// 同步块 C
   }
}

Java 6 中引入了偏向锁来做进一步优化。

偏向锁升级过程如下:

  1. 一个线程在第一次进入同步块时,会在对象头和栈帧中的锁记录里存储锁的偏向的线程ID;
  2. 下次该线程进入这个同步块时,会去检查锁的Mark Word里面是不是放的自己的线程ID:
    • 如果是,表明该线程已经获得了锁,以后该线程在进入和退出同步块时不需要花费CAS操作来加锁和解锁
    • 如果不是,就代表有另一个线程来竞争这个偏向锁。这个时候会尝试使用CAS来替换Mark Word里面的线程ID为新线程的ID,这个时候要分两种情况:成功,表示之前的线程不存在了, Mark Word里面的线程ID为新线程的ID,锁不会升级,仍然为偏向锁;失败,表示之前的线程仍然存在,那么暂停之前的线程,设置偏向锁标识为0,并设置锁标志位为00,升级为轻量级锁,会按照轻量级锁的方式进行竞争锁。

偏向锁撤销过程:

偏向锁使用了一种等到竞争出现才释放锁的机制,所以当其他线程尝试竞争偏向锁时, 持有偏向锁的线程才会释放锁。

偏向锁升级成轻量级锁时,会暂停拥有偏向锁的线程,重置偏向锁标识:

  • 在一个安全点(在这个时间点上没有字节码正在执行)停止拥有锁的线程。
  • 遍历线程栈,如果存在锁记录的话,需要修复锁记录和Mark Word,使其变成无锁状态。
  • 唤醒被停止的线程,将当前锁升级成轻量级锁。

整个过程如下如所示:

java并发编程 3:synchronized与锁(重点)_第15张图片

一般只有一个线程使用锁比较适合偏向锁。如果应用程序里所有的锁通常出于竞争状态,那么偏向锁就会是一种累赘,Java15后逐步废除偏向锁。可以一开始就把偏向锁这个默认功能给关闭:运行时在添加 VM 参数 -XX:-UseBiasedLocking 可禁用偏向锁。

回忆下前面说的对象头的mark word信息。一个对象创建时:

  • 如果开启了偏向锁(默认开启),那么对象创建后,markword 值为 0x05 即最后 3 位为 101,这时它的thread、epoch、age 都为 0
  • 偏向锁是默认是延迟的,不会在程序启动时立即生效,如果想避免延迟,可以加 VM 参数 -XX:BiasedLockingStartupDelay=0 来禁用延迟
  • 如果没有开启偏向锁,那么对象创建后,markword 值为 0x01 即最后 3 位为 001,这时它的 hashcode、age 都为 0,第一次用到 hashcode 时才会赋值

注意:

  • 当处于偏向锁的对象解锁后,对象头中的线程 ID 不会立即清除。相反,JVM 会延迟清除线程 ID。这样做是为了提高性能,在短时间内再次加锁时可以利用已经存在的偏向锁,而不需要重新竞争锁。

  • 调用了对象的 hashCode,但偏向锁的对象 MarkWord 中存储的是线程 id,如果调用 hashCode 会导致偏向锁被

    撤销。

为什么轻量级锁和重量级锁调用了对象的 hashCode不会影响锁状态?

因为偏向锁的的线程id是存在对象头里,当偏向锁调用了 hashCode,占用了对象头的31位,没有足够的空间存储线程id信息了。但是轻量级锁的线程id信息存储在线程的栈帧里,重量级锁存储在monitor中,不受影响。

  • 当有其它线程使用偏向锁对象时,会将偏向锁升级为轻量级锁

有偏向锁就使用偏向锁,没有就优先使用轻量级锁,当有线程竞争锁,就会转为重量级锁。

偏向锁的重偏向和批量撤销

批量重偏向

如果对象虽然被多个线程访问,但没有竞争,这时偏向了线程 T1 的对象仍有机会重新偏向 T2,重偏向会重置对象的 Thread ID

当撤销偏向锁阈值超过 20 次后,jvm 会这样觉得,我是不是偏向错了呢,于是会在给这些对象加锁时重新偏向至加锁线程。

批量撤销

当撤销偏向锁阈值超过 40 次后,jvm 会这样觉得,自己确实偏向错了,根本就不该偏向。于是整个类的所有对象都会变为不可偏向的,新建的对象也是不可偏向的。

你可能感兴趣的:(java并发编程JUC,java,jvm,并发编程)