java并发编程(二)——加锁

线程安全

举个栗子:如果A窗口和B窗口在售卖同样的100张票,当这100张票卖完时,A窗口和B窗口关闭。看下代码实现

public class Ticket implements Runnable{

    private int ticket = 5;

    public void run(){
        while (ticket > 0){
               System.out.println(Thread.currentThread().getName() + "正在出售" + ticket + "号票");
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            ticket--;
        }
    }

    public static void main(String[] args) {
        Ticket ticket = new Ticket();
        Thread a = new Thread(ticket,"A");
        Thread b = new Thread(ticket,"B");

        a.start();
        b.start();
    }
}

/*
* 运行结果
* A正在出售5号票
* B正在出售5号票
* B正在出售3号票
* A正在出售4号票
* A正在出售2号票
* B正在出售1号票
*/

由上面的程序和运行结果我们可以看到,线程A在打印自己出售的是哪张票后会睡眠1秒,而此时ticket并没有进行减减操作,这时候线程B也开始打印自己出售的是哪张票,因为ticket的值并没有减少,所以线程B打印的也是正在出售5号票。这样就会使两个窗口同时出售同一张票,在现实中是不允许的。这就是线程不安全的情况。为了不让这种情况发生,我们就需要保证线程安全。

临界区

一个程序运行多个线程本身是没有问题的,问题出在多个线程访问共享资源,多个线程读共享资源其实也没有问题,在多个线程对共享资源读写操作时发生指令交错,就会出现问题。一段代码块内如果存在对共享资源的多线程读写操作,称这段代码块为临界区。

static int counter = 0;
static void increment() 
// 临界区
{ 
 counter++; }
static void decrement() 
// 临界区
{ 
 counter--; }

要解决线程对临界资源读写时出现的问题,处理办法有多种。包括:

1、阻塞式的解决方案:synchronized,Lock

2、非阻塞式的解决方案:原子变量

synchronized解决方案

synchronized也叫对象锁,采用互斥的方式让同一时刻只有一个线程持有对象锁,其他线程想再获取这个锁,就会阻塞,这样就能保证拥有锁的线程可以安全的执行临界资源的代码,不用担心线程的上下文切换。

其实就是A窗口和B窗口都在卖票,A窗口先来了顾客,这时候A就把票锁住了,虽然票还没有卖出去,但B窗口已经不能再去操作票了。等A窗口卖出一张票后,A和B窗口再同时开始卖票,谁先来了顾客,谁就会把票锁住。

虽然 java 中互斥和同步都可以采用 synchronized 关键字来完成,但它们还是有区别的:互斥是保证同一时刻只能有一个线程执行临界区代码;同步是由于逻辑上线程的先后顺序,需要一个线程等待另一个线程执行到某个点。

使用synchronized解决卖票问题

/*synchronized语法
* synchronized(对象){             //对象可以是任意,但要保证需要处理的多个线程使用的锁对象的是同一个对象
*
*       临界区
*
* }
*
* */

public class Ticket implements Runnable{

    private int ticket = 5;

    Object object = new Object();  //将object作为锁对象

    public void run(){
        while (true){
            synchronized (object){    //将下面临界区代码锁住,保证A和B只有一个执行
               if (ticket > 0){
                   System.out.println(Thread.currentThread().getName() + "正在出售" + ticket + "号票");
                   try {
                       Thread.sleep(100);
                   } catch (InterruptedException e) {
                       e.printStackTrace();
                   }
                   ticket--;
               }else {
                   break;    //票卖完了就停止线程
               }
            }
        }
    }

    public static void main(String[] args) {
        Ticket ticket = new Ticket();
        Thread a = new Thread(ticket,"A");
        Thread b = new Thread(ticket,"B");

        a.start();
        b.start();
    }
}

/*
* 运行结果
* A正在出售5号票
* B正在出售4号票
* B正在出售3号票
* A正在出售2号票
* A正在出售1号票
*/

 注意,如果一个线程持有对象锁时,它的CPU时间片用完了但是该线程还没有执行完,那么该线程仍然持有这把锁,其他的线程不能执行该临界区代码,当该线程再次分配到CPU时,它会继续执行,也就是说直到该线程执行完后它才会释放锁对象。synchronized实际是用对象锁保证了临界区代码的原子性,临界区内的代码对外是不可分割的,不会被线程切换所打断。

synchronized的使用

1、同步代码块,上面使用的方式就是同步代码块。

2、同步方法

class Test{
    public synchronized void test() {

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

        }
    }
}

3、静态同步方法 

class Test{
    public synchronized static void test() {
    }
}
等价于
class Test{
    public static void test() {
        synchronized(Test.class) {  //这里需要注意类对象和这个类的实例对象不是同一个对象

        }
    }
}

变量的线程安全分析

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

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

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

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

局部变量是否线程安全?

1、局部变量是线程安全的

2、但局部变量引用的对象则未必

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

局部变量线程安全分析

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

/*
* 每一个线程都有自己私有的堆栈,本地方法区和程序计数器
* 线程会将方法进行压栈操作,方法中的局部变量每个线程都会在栈桢内存中进行存储
* 所以局部变量 i 不存在共享,没有线程安全问题
* */

成员变量线程安全分析

import java.util.ArrayList;

public class Demo{

    public static void main(String[] args) {

        ThreadUnsafe test = new ThreadUnsafe();

        new Thread(() -> {
            test.method1();
        }, "Thread" + "A").start();

        new Thread(() -> {
            test.method1();
        }, "Thread" + "B").start();

    }
}

class ThreadUnsafe {

    ArrayList<String> list = new ArrayList<>();    //成员变量 list,两个线程共享

    public void method1() {
        for (int i = 0; i < 200000; i++) {
            // { 临界区, 可能出现线程安全问题

            method2();
            method3();

            // } 临界区
        }
    }
    private void method2() {
        list.add("1");
    }
    private void method3() {
        list.remove(0);
    }
}

/*运行结果会报异常Exception in thread "ThreadB" java.lang.IndexOutOfBoundsException
*原因是线程A和线程B共享了成员变量list,这样可能会出现
* 线程A拿到空表list,在做add操作还没写回时,线程B也拿到了表list,
* 因为线程A还没有写回,所以线程B拿到的也是一张空表,
* 这个时候A写回后表里有一个数据,list[0]为 1,而此时B也开始写回,
* B写回时也会把list[0]赋值为 1,也就是说B写回时覆盖了A的内容,
* 此时两个线程写回后,表里只有一个数据,接着执行两次remove操作,就会出现异常
* */

将上面的例子做下修改,把成员变量list放到method1中,作为局部变量,再将list的引用作为参数传递给method2和method3,这样问题就解决了。

import java.util.ArrayList;

public class Demo{

    public static void main(String[] args) {

        ThreadUnsafe test = new ThreadUnsafe();

        new Thread(() -> {
            test.method1();
        }, "Thread" + "A").start();

        new Thread(() -> {
            test.method1();
        }, "Thread" + "B").start();

    }
}

class ThreadUnsafe {

    public void method1() {
        for (int i = 0; i < 200000; i++) {

            ArrayList<String> list = new ArrayList<>();  //局部变量 list,每个线程都会在自己的堆栈中进行存储

            // { 临界区, 可能出现线程安全问题

            method2(list);
            method3(list);

            // } 临界区
        }
    }
    private void method2( ArrayList<String> list) {
        list.add("1");
    }
    private void method3( ArrayList<String> list) {
        list.remove(0);
    }
}

/*list 是局部变量,每个线程调用时会创建其不同实例,没有共享
* 而 method2 的参数是从 method1 中传递过来的,与 method1 中引用同一个对象
* method3 的参数分析与 method2 相同
* */

 暴露局部变量的引用

在上面的例子中,如果把method2和method3的访问修饰符改为public,这样线程A和线程B之间还是不会出错,但如果在添加一个类,这各类继承ThreadUnsafe这个类并重写了method3,在method3中新建一个线程并执行,这样也会出现上面的异常。

import java.util.ArrayList;

public class Demo{

    public static void main(String[] args) {

        ThreadUnsafeSubClass test = new ThreadUnsafeSubClass();

        new Thread(() -> {
            test.method1();
        }, "Thread" + "A").start();

        new Thread(() -> {
            test.method1();
        }, "Thread" + "B").start();



    }
}

class ThreadUnsafe {

    public void method1() {
        for (int i = 0; i < 20000; i++) {

            ArrayList<String> list = new ArrayList<>();  //局部变量 list,每个线程都会在自己的堆栈中进行存储

            // { 临界区, 可能出现线程安全问题

            method2(list);
            method3(list);

            // } 临界区
        }
    }
    public void method2( ArrayList<String> list) {
        list.add("1");
    }
    public void method3( ArrayList<String> list) {
        list.remove(0);
    }
}

class ThreadUnsafeSubClass extends ThreadUnsafe{
    @Override
    public void method2(ArrayList<String> list) {
        new Thread(() -> {
            list.add("1");
        }).start();
    }
}
/*list 虽然是局部变量,但是子类方法中的list对象和父类方法中是同一个,共享会出现问题
* */

Monitor机制——synchronized底层

上面我们已经知道了synchronized可以用锁的办法保证线程的安全性(互斥),但是它在底层是如何实现的呢?这就需要了解Monitor机制了。在学习Monitor机制之前,我们先看下java对象在内存中是如何存储的。

Java对象保存在内存中时,由三部分组成:对象头,实例数据,对齐填充字节(JVM要求对象占用的空间必须是8 的倍数)。

java对象头

一个对象在存储时,为了实现一些额外的功能,可能需要对这个对象做一些标记,这些标记标记字段就组成了对象头。

java对象头由三部分组成:Mark Word,Klass Word(指向类的指针),Array Length(数组长度,只有数组对象才有)

Mark Word:用于存储对象自身的运行时数据,如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等等,占用内存大小与虚拟机位长一致。当一个对象被synchronized关键字当成同步锁时,围绕这个锁的一系列操作都和Mark Word有关。Mark Word在32位JVM中的长度是32bit,在64位JVM中长度是64bit。下图显示的是32位JVM中Mark Word的存储方式。

|-------------------------------------------------------|--------------------|
|                  Mark Word (32 bits)                  |       State        |
|-------------------------------------------------------|--------------------|
| identity_hashcode:25 | age:4 | biased_lock:1 | lock:2 |       Normal       |
|-------------------------------------------------------|--------------------|
|  thread:23 | epoch:2 | age:4 | biased_lock:1 | lock:2 |       Biased       |
|-------------------------------------------------------|--------------------|
|               ptr_to_lock_record:30          | lock:2 | Lightweight Locked |
|-------------------------------------------------------|--------------------|
|               ptr_to_heavyweight_monitor:30  | lock:2 | Heavyweight Locked |
|-------------------------------------------------------|--------------------|
|                                              | lock:2 |    Marked for GC   |
|-------------------------------------------------------|--------------------|

Mark Word在不同的锁状态下存储的内容不同,其中无锁和偏向锁的锁标志位都是01,只是在前面的1bit区分了这是无锁状态还是偏向锁状态(在下面synchronized优化中会讲到)。

锁状态

25bit

4bit

1bit

2bit

23bit

2bit

是否偏向锁

锁标志位

无锁

对象的HashCode

分代年龄

0

01

偏向锁

线程ID

Epoch

分代年龄

1

01

轻量级锁

指向栈中锁记录的指针

00

重量级锁

指向重量级锁的指针

10

GC标记

11

JVM一般是这样使用锁和Mark Word的:

1,当没有被当成锁时,这就是一个普通的对象,Mark Word记录对象的HashCode,锁标志位是01,是否偏向锁那一位是0。

2,当对象被当做同步锁并有一个线程A抢到了锁时,锁标志位还是01,但是否偏向锁那一位改成1,前23bit记录抢到锁的线程id,表示进入偏向锁状态。

3,当线程A再次试图来获得锁时,JVM发现同步锁对象的标志位是01,是否偏向锁是1,也就是偏向状态,Mark Word中记录的线程id就是线程A自己的id,表示线程A已经获得了这个偏向锁,可以执行同步锁的代码。

4,当线程B试图获得这个锁时,JVM发现同步锁处于偏向状态,但是Mark Word中的线程id记录的不是B,那么线程B会先用CAS操作试图获得锁,这里的获得锁操作是有可能成功的,因为线程A一般不会自动释放偏向锁。如果抢锁成功,就把Mark Word里的线程id改为线程B的id,代表线程B获得了这个偏向锁,可以执行同步锁代码。如果抢锁失败,则继续执行步骤5。

5,偏向锁状态抢锁失败,代表当前锁有一定的竞争,偏向锁将升级为轻量级锁。JVM会在当前线程的线程栈中开辟一块单独的空间,里面保存指向对象锁Mark Word的指针,同时在对象锁Mark Word中保存指向这片空间的指针。上述两个保存操作都是CAS操作,如果保存成功,代表线程抢到了同步锁,就把Mark Word中的锁标志位改成00,可以执行同步锁代码。如果保存失败,表示抢锁失败,竞争太激烈,继续执行步骤6。

6,轻量级锁抢锁失败,JVM会使用自旋锁,自旋锁不是一个锁状态,只是代表不断的重试,尝试抢锁。从JDK1.7开始,自旋锁默认启用,自旋次数由JVM决定。如果抢锁成功则执行同步锁代码,如果失败则继续执行步骤7。

7,自旋锁重试之后如果抢锁依然失败,同步锁会升级至重量级锁,锁标志位改为10。在这个状态下,未抢到锁的线程都会被阻塞。

Klass Word:指向类的指针,该指针在32位JVM中的长度是32bit,在64位JVM中长度是64bit。JVM通过这个确定这个对象属于哪个类。

Array Length:数组长度,只有数组对象保存了这部分数据。32位的JVM上,长度为32位;64位JVM则为64位。64位JVM如果开启 UseCompressedOops 选项,该区域长度也将由64位压缩至32位。

走近Monitor

什么是monitor?

monitor直译过来是监视器的意思,专业一点叫管程。操作系统在解决线程同步问题时,会经过一系列的原语操作,程序员在使用这些原语时稍不小心就会引发问题,为了更好地实现并发编程,在操作系统支持的同步原语之上,又提出了更高层次的同步原语 monitor,它本质上是jvm用c语言定义的一个数据类型。值得注意的是,操作系统本身并不支持 monitor 机制,monitor是属于编程语言级别的,也就是当你想用monitor解决线程同步问题时,你得先看下你所使用的语言是否支持monitor原语。总的来说,monitor的出现是为了解决操作系统级别关于线程同步原语的使用复杂性,对复杂操作进行封装。而java则基于monitor机制实现了它自己的线程同步机制,就是synchronized内置锁。

monitor的作用

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

monitor的组成

1、monitor对象:使用monitor机制的目的主要是为了互斥进入临界区,为了做到能够阻塞无法进入临界区的 进程/线程,还需要一个monitor对象来协助。monitor对象是monitor机制的核心,这个monitor对象内部会有相应的数据结构,例如列表,来保存被阻塞的线程,它本质上是jvm用c语言定义的一个数据类型。同时由于 monitor 机制是基于 mutex 这种基本原语的,所以 monitor 对象还必须维护一个基于 mutex 的锁,monitor的线程互斥就是通过mutex互斥锁实现的。

2、临界区:多个线程对共享资源读写操作的那段代码,其实就是synchronized包裹起来的那段代码。

3、条件变量:条件变量的使用与下面 wait() 和 signal() 方法的使用密切相关,它是为了在适当的时候阻塞或者唤醒一个进程/线程。在线程获取锁进入临界区之后,如果发现条件变量不满足,monitor使用 wait() 使线程阻塞,条件变量满足后使用 signal() 唤醒被阻塞线程。

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

举个栗子:监视器可以看做是经过特殊布置的建筑,这个建筑有一个特殊的房间,该房间通常包含一些数据和代码,但是一次只能一个消费者(线程)使用此房间,当一个消费者(线程)使用了这个房间,首先他必须到一个大厅(Entry Set)等待,调度程序将基于某些标准(如:先进先出)将从大厅中选择一个消费者(线程),进入特殊房间,如果这个线程因为某些原因被“挂起”,它将被调度程序安排到“等待房间”,并且一段时间之后会被重新分配到特殊房间,按照上面的线路,这个建筑物包含三个房间,分别是“特殊房间”、“大厅”以及“等待房间”。简单来说,监视器用来监视线程进入这个特别房间,他确保同一时间只能有一个线程可以访问特殊房间中的数据和代码。

每个java对象都会与一个monitor相关联,当一个线程访问到一个对象的临界区代码时(使用synchronized修饰的),就会根据该对象的对象头中的Mark Word信息,找到与之关联的monitor对象,如果发现monitor对象中的特殊房间没有其他线程在使用,就会进入特殊房间,否则进入到大厅等候(BLOCKED状态)。如果一个线程进入了特殊房间,但因为它的条件变量不满足而不得不退出特殊房间,比如调用了wait(),此时这个线程就会进入等待房间被挂起(WAITING状态),直到它的条件变量满足后会再被分配到特殊房间。

WaitSet:存放处于WAITING状态的线程队列。

EntryList:存放处于等待获取锁BLOCKED状态的线程队列,即被阻塞的线程。

Owner:指针,指向持有Monitor对象的线程。

java中Monitor的实现

先看下使用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

上面的字节码显示,从序号 0 到 4,java会获取对象 lock 的引用并把它存到 slot_1中,这是为了在解锁时还原对象信息。序号 5 把 lock 对象的 Mark Word 修改为指向该对象锁关联的Monitor指针。接下来就会在Monitor对象中互斥的执行count++操作,也就是序号 6 到 11。当线程执行完后,也就是到了序号14,会把之前存储在slot_1中 lock 对象的引用取出来,将 lock 对象的Mark Word再次还原为之前的信息,之后唤醒EntryList中其他的线程。序号 16 之后的代码是为了保证当synchronized中的代码出现异常时,也能够释放锁。

从上面可以看出,同步代码块是使用 monitorenter 和 monitorexit 指令包裹临界区实现同步。附:同步方法是使用ACC_SYNCHRONIZED方法访问标识符实现同步。

Synchronized优化

前面的学习我们知道了synchronized在底层是通过Monitor对象来实现线程同步的。但如果只是像上面一样,每个线程在执行临界区代码时都会通过Monitor对象,执行操作系统的同步原语,会浪费很多资源,因为操作系统实现线程之间的切换时需要从用户态转换到核心态,这个状态之间的转换需要相对比较长的时间,时间成本相对较高,这也是为什么早期的synchronized效率低的原因,线程像这种依赖操作系统互斥量的加锁机叫重量级锁。为了提高程序的执行效率,java对synchronized做出了优化,引入了各种锁。

锁状态

25bit

4bit

1bit

2bit

23bit

2bit

是否偏向锁

锁标志位

无锁

对象的HashCode

分代年龄

0

01

偏向锁

线程ID

Epoch

分代年龄

1

01

轻量级锁

指向栈中锁记录的指针

00

重量级锁

指向重量级锁的指针

10

GC标记

11

轻量级锁

前面的学习中我们使用了很多的测试程序,在执行这些测试程序时,其实并不是每次都会出现线程安全的问题,很多时候运行结果是正常的。

多个线程在执行临界区代码时,并不一定出现同时对共享资源的读写操作,很大可能是各个线程交替执行的,那就没必要每次都去执行操作系统的同步原语,基于这种情况,引入了轻量级锁的概念。引入轻量级锁的主要目的是,在没有多线程竞争的前提下,减少传统的重量级锁使用操作系统互斥量产生的性能消耗(多指时间消耗)。

轻量级锁的加锁过程

1、当一个线程执行同步块(synchronized包裹的那部分临界区代码)的时候,如果同步对象(锁对象)的锁状态为无锁状态(无锁状态的锁标志位为 "01",轻量级锁的锁标志位为 "00"),该线程会在自己的栈桢中创建一个锁记录(Lock Record)对象。每一个线程的栈桢中都会包含一个锁记录的结构,内部Displaced Mard Word用于存储同步对象的Mark Word,Owner指针用来记录同步对像的地址。

2、拷贝同步对像的Mark Word,并将其赋值给锁记录对象的Displaced Mard Word,将锁记录对象的Owner指针指向同步对像,而同步对象中的Mark Word则存储了锁记录对象的地址和锁状态 "00"。

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

4、如果上面的更新操作失败了,那么虚拟机首先会检查同步对象的Mark Word是否指向当前线程的栈帧,如果是就说明当前线程已经拥有了这个对象的锁,那就可以直接进入同步块继续执行(这种情况被称作锁重入,看下面下面代码)。否则说明多个线程竞争锁,轻量级锁就要膨胀为重量级锁,锁标志的状态值变为“10”,Mark Word中存储的就是指向重量级锁(互斥量)的指针,后面等待锁的线程也要进入阻塞状态。

锁重入:同一个线程在不同地方使用了同一个锁对象。

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

假如线程在执行同步块A的时候获取了锁对象obj,当它执行method2()时,又会再一次尝试获取锁对象obj,由于此时锁对象obj的Mark Word中已经指向了当前线程的栈桢,所以虚拟机会判断当前线程持有该锁对象继续执行。然后该线程会在自己的栈桢中再创建了锁记录对象,该锁记录对象的Displaced Mard Word为null,当同步代码执行完后会进行解锁,如果锁记录对象的Displaced Mard Word为null时,就会删除该锁记录对象,如果不为null,就将锁记录对象中Displaced Mard Word赋值给锁对象的Mark Word,并且释放该锁对象,重置锁状态为 "01"。如果上面的解锁过程失败了,那么轻量级锁就会膨胀成重量级锁。

锁膨胀:当一个线程A以轻量级锁的方式获得一个锁对象后,另一个线程B也想获取该锁对象,线程B开始会以轻量级锁的方式尝试获取,尝试获取失败后发现有其他线程持有该锁对象,就会用重量级锁的方式获取该锁对象,即轻量级锁膨胀为重量级锁。这时候线程B会进入Monitor对象的Entry List中,并且锁对象的Mark Word的值会复制给Monitor对象的Owner指针,即Monitor对象的Owner指针会指向当前持有锁对象的线程A中的锁记录对象,然后把锁对象的Mark Word修改为Monitor对象的地址,并把锁状态设置为 "10"。当线程A执行完需要重置锁对象的Mark Word时,发现锁状态为 "10"从而更新失败,此时线程A就会进入重量级锁流程,根据锁对象的Mark Word找到Monitor对象,设置Monitor对象中的Owner指针为null,然后唤醒Entry List中的其他线程。至此线程A会释放锁,并且Entry List中的线程B会获得锁并执行,直到执行结束释放锁。当线程B释放锁后,其他线程又可以用轻量级锁的方式获取该锁对象。

锁自旋:由上面我们知道,一个线程会先以轻量级锁的方式去获取锁对象,如果该锁对象已经被别的线程加锁时,那么这个线程就会进入阻塞状态(BLOCKED)。但是,线程在阻塞与唤醒之间切换时会占用很多CPU的时间,而且有可能该线程刚进入阻塞状态就会被唤醒,为了减少CPU的时间消耗,引入了锁自旋。锁自旋就是当一个线程未获取到锁对象时,不会立刻进入阻塞状态,而是循环的去尝试获取锁,在一定次数后还未获取到锁,则会进入阻塞状态。JDK1.4.2中引入了锁自旋,并且在JKD1.6中得到了优化。在JDK1.6中,线程的循环次数默认为10次,当10次还未获取到锁时就会进入阻塞状态。线程在循环获取锁的时候同样会占用CPU时间,设定一个合适的循环次数是比较困难的,所以JDK1.6中还提供了自适应锁自旋,即循环的次数不是固定的,当这个锁对象上有线程自旋成功,即线程在一定循环次数内获取到了锁对象,那么虚拟机会认为这个锁对象的其他线程上次能成功,这次应该也能成功,就会增加自旋次数;反之,如果这个锁对象上其他线程自旋成功很少或者没有,就会减少自旋的次数甚至不自旋以避免CPU资源过多的浪费。

轻量级锁的释放

1、根据锁记录对象中的Owner指针找到锁对象,如果锁对象的锁状态位为 "00",则将锁对象中的Displaced Mard Word还原给锁对象,设置锁状态位为 "01",释放锁。

2、如果上述步骤操作失败,即轻量级锁已经膨胀为重量级锁,那么根据锁对象的Mark Word,找到与之关联的Monitor对象

3、将Monitor对象中的Owner指针设置为空,唤醒Entry List中阻塞的线程,释放锁并由新的线程竞争。

偏向锁

引入轻量级锁的目的:多个线程之间往往会交替的执行临界区代码,为了避免每次都使用重量级锁通过Monitor机制调用操作系统的同步原语而浪费CPU资源,引入了轻量级锁。

偏向锁的目的:大多数情况下,锁不仅不存在多线程竞争,而且往往是同一个线程多次获得,因此为了减少同一线程获取锁(会涉及到一些CAS的耗时操作)的代价而引入偏向锁。由上面Mark Word的信息,偏向锁的Mark Word后三位为 "101",而正常状态为 "001"。

偏向锁的思想:线程会先以偏向锁的方式获取锁,然后锁对象的Mark Word中会存储当前线程的ID(操作系统分配的,用户不可操作),之后同一个线程再次来获取锁对象时不需要做任何操作,这样就减轻了每次重复的加锁和释放过程(cas操作)。偏向锁只会在第一次线程与Mark Word交换信息时使用CAS操作,当竞争出现时偏向锁就会撤销并且膨胀为轻量级锁。所以,如果要节省时间消耗,偏向锁撤销的耗时必然要小于节省下来的那些CAS操作。也就是说,偏向锁适用于线程锁竞争不激烈的环境,如果锁竞争激烈,频繁的做锁撤销操作,会浪费更多的时间。

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

偏向锁的加锁过程

JVM默认偏向锁是开启的,也可以通过(-XX:+UseBiasedLocking)设置偏向锁开启,偏向锁默认是有延迟的,即不会再程序启动时立刻开启,可以通过(-XX:BiasedLockingStartupDelay=0)设置延迟为0。

1、检测锁对象的Mark Word是否为可偏向状态,锁状态位(Mark Word最后两位)为 "01",是否偏向位biased标志位(Mark Word倒数第三位)为 "1"表示可偏向,biased标志位为 "0"表示不可偏向。即对象可设置偏向锁,则Mark Word最后三位为 "101"。如果不可偏向则以轻量级方式获取锁。

2、如果锁对象可偏向,检测当前线程ID是否和锁对象中记录的线程ID一样,如果一样就执行第5步。

3、如果当前线程ID和锁对象中记录的线程ID不一样,就通过CAS操作(一些原子操作)尝试获取锁对象,如果获取成功将(即没有其他线程持有锁对象)则将当前线程ID存入锁对象中的Mark Word中并执行第5步。

4、如果获取失败(即已经有线程以偏向锁的方式持有了锁对象),则需要对偏向锁进行撤销,并将锁升级成轻量级锁,以轻量级锁的方式继续执行。撤销操作需要等到全局安全点(此时没有线程在执行字节码)。

5、执行临界区代码。

轻量级锁和偏向锁的使用场景为:轻量级锁是为了在线程交替执行同步块时提高性能,而偏向锁则是在只有一个线程执行同步块时进一步提高性能。

偏向锁的撤销:偏向锁的撤销,需要等待全局安全点(在这个时间点上没有字节码正在执行),它会首先暂停拥有偏向锁的线程,然后检查持有偏向锁的线程是否活着,如果线程不处于活动状态,则将对象头设置成无锁状态,如果线程仍然活着,恢复到无锁(将锁对象的标志位设置为0,表示该对象不适合作偏向锁),最后唤醒之前暂停的线程。偏向锁只有遇到其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁,线程不会主动去释放偏向锁。

偏向锁撤销后锁对象可能存在两种状态:

  • 不可偏向,未锁定状态。即锁对象的Mark Word中biased标志位为 "0",最后两位锁标志位为 "01"。出现这种情况是因为已偏向的锁对象在调用hashCode()时,会禁用掉偏向锁,如果这个锁对象又没有被其它线程持有,就会进入一种不可偏向的无锁状态。至于为什么会出现掉用hashCode()后禁用偏向锁,从上面对象头那节我们知道在32位系统中,对象的Mark Word中,HashCode占25个字节,而线程ID占23个字节,调用hashCode()时,一个已经偏向的对象的Mark Word中已经没有足够的字节存储HashCode,所以需要禁用偏向锁。
  • 轻量级锁状态。即锁对象的Mark Word中biased标志位为 "0",最后两位锁标志位为 "00"。这种情况是其它线程竞争偏向锁时,会使偏向锁膨胀为轻量级锁。

偏向锁的释放

偏向锁在直观意义上没有释放锁的过程,因为只要没有其他线程来竞争锁,这个锁对象中会一直存着上一个以偏向锁的方式获取锁对象的线程ID。不会尝试将 Mark Word 中的 Thread ID 赋回原值。这样做的好处是: 如果该线程需要再次对这个对象加锁,而这个对象之前一直没有被其他线程尝试获取过锁,依旧停留在可偏向的状态下, 即可在不修改对象头的情况下, 直接认为偏向成功。

批量重偏向

没搞懂!!!

BiasedLocking模式下markOop中位域epoch的根本作用是什么?

java 偏向锁

偏向锁

偏向锁详解

不同锁对象之间的状态转换

上面理解了的话,对这个图也很好理解。

wait和notify

wait()和sleep()的区别

1、sleep()是Thred类中的一个静态方法,wait是Object类中的成员方法。

2、wait()只能用在synchronized块中,而sleep()可以用在任何地方。

3、使用sleep()时可能出现异常,需手动处理(捕获或者抛出),而使用wait不用手动处理。

4、使用wait()当前线程会放弃锁,如果指定时间,该线程会进入TIMED_WAITING状态,如果不指定时间会进入WAITING状态。而使用sleep()当前线程不会放弃锁,进入TIMED_WAITING态。

notify

当多个线程,对同一个对象object调用了object.wait()后,如果使用object.notify()会随机的唤醒一个线程,而使用object.notifyAll()会唤醒所有等待的线程。那么如何只唤醒我们需要的线程呢?我们只需要将判断时的if换成while就行了。

public class Demo {

    static final Object room = new Object();
    static boolean hasCigarette = false;
    static boolean hasTakeout = false;

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

        new Thread(() -> {
            synchronized (room) {
                System.out.println("有烟没?"+hasCigarette);
                //如果将此处改成while,那么即使唤醒了该线程,只要hasCigarette为false,就会继续等待
                if (!hasCigarette) {
                    System.out.println("没烟,先歇会!");
                    try {
                        room.wait();
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }
                System.out.println("有烟没?"+hasCigarette);
                if (hasCigarette) {
                    System.out.println("可以开始干活了");
                } else {
                    System.out.println("没干成活...");
                }
            }
        }, "小南").start();
        new Thread(() -> {
            synchronized (room) {
                Thread thread = Thread.currentThread();
                System.out.println("外卖送到没?"+hasTakeout);
                if (!hasTakeout) {
                    System.out.println("没外卖,先歇会!");
                    try {
                        room.wait();
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }
                System.out.println("外卖送到没?"+hasTakeout);
                if (hasTakeout) {
                    System.out.println("可以开始干活了");
                } else {
                    System.out.println("没干成活...");
                }
            }
        }, "小女").start();
        Thread.sleep(1);
        new Thread(() -> {
            synchronized (room) {
                hasTakeout = true;
                System.out.println("外卖到了噢!");
                room.notify();
            }
        }, "送外卖的").start();
    }
}
/*
    有烟没?false
    没烟,先歇会!
    外卖送到没?false
    没外卖,先歇会!
    外卖到了噢!
    有烟没?false
    没干成活...
 */

 设计模式——保护性暂停

如果线程1需要等待线程2执行完之后才能执行,我们可以在线程1中使用wait()来解决。如果线程1中还需用用到线程2的结果,那么我们只能通过全局变量的形式将线程2的运行结果传递给线程1。这与面向对象的理念不符,为了解决这个问题,一个线程需要用到另一个线程的结果,我们可以使用保护性暂停设计模式。

保护性暂停模式:使用一个保护性对象GuardedObject,里面有两方法get()和set(),以及一个用于传递线程2的返回值的私有属性response。

package com.bingfa;

public class GuardedTest {

    public static void main(String[] args) {
        GuardedObject guardedObject = new GuardedObject();

        new Thread(()->{
            int sum = 0;
            int a = 10;
            int b;
            b = Integer.parseInt(guardedObject.get().toString()); //此时线程1会进入WAITING状态
            sum = a + b;
            System.out.println("sum = " + sum);
        }, "线程2").start();

        new Thread(()->{
            int response = 0;
            response = 1 + 1;
            guardedObject.set(response);  //线程2执行完后将response通过guardedObject对象返回给线程1,并唤醒了线程1
        }, "线程2").start();
    }

}

class GuardedObject{

    private Object response;   //用于结果的传递

    public synchronized Object get() {
        while(response == null){    //此处使用while,上面已经介绍过
            try {
                System.out.println("等待线程2的结果");
                this.wait();   //线程2没有返回结果,继续等待
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }

        return response;  //线程2已经返回response,则线程1可以使用了
    }

    public synchronized void set(Object response){
        System.out.println("线程2结果返回");
        this.response = response;
        this.notifyAll();   //已经产生结果,唤醒等待的线程
    }
}

 park & unpark

park 和 unpark是LockSupport类中的两个静态方法,LockSupport.park()和LockSupport.unpark()。作用与wait和notify类似,不过wait和notify需要在monitor通过来实现,而park和unpark是以线程为单位,并且unpark可以明确唤醒哪个线程。LockSuppor.unpark(t1),唤醒t1线程。unpark表示一种许可,一个线程可以先获取许可,之后使用park时如果有这种许可就不会进入等待状态,即unpark可以先于park使用。

参考资料

java对象头详解

java对象头和对象组成详解

面试官和我扯了半个小时的synchronized,最后他输了

Java 中的 Monitor 机制

java并发系列-monitor机制实现

监视器–JAVA同步基本概念

Java Synchronized实现原理

Java并发编程:Synchronized底层优化(偏向锁、轻量级锁)

java 偏向锁

原文地址:https://www.cnblogs.com/Zz-feng/p/13153723.html