阅读 140

深入Java虚拟机(六)线程同步

可以在语言级支持多线程是Java语言的一大优势,这种支持主要集中在同步上,或调节多个线程间的活动和共享数据。Java所使用的同步是监视器

监视器Monitor

Java中的监视器支持两种线程:互斥和协作

  • 虚拟机通过对象锁来实现互斥,允许多个线程在同一个共享数据上独立而不干扰地工作
  • 协作则是通过Object类的wait方法和notify方法来实现,允许多个线程为了同一个目标而共同工作

我们可以把监视器比作一个建筑,它有一个很特别的房间,房间里有一些数据,而且在同一时间只能被一个线程占据。一个线程从进入这个房间到它离开之前,它可以独占地访问房间中的全部数据。

我们用一些术语来定义这一系列动作:

  • 进入建筑叫做进入监视器
  • 进入建筑中的那个特别的房间叫做获得监视器
  • 占据房间叫做持有监视器
  • 离开房间叫做释放监视器
  • 离开建筑叫做退出监视器

除了与一些数据关联外,监视器还是关联到一些或更多的代码,这样的代码称作监视区域,对于一个监视器来说,监视区域是最小的、不可分割的代码块。而监视器会保证在监视区域上同一时间只会执行一个线程。一个线程想要进入监视器的唯一途径就是到达该监视器所关联的一个监视区域的开始处,而线程想要继续执行监视区域的唯一途径就是获得该监视器

监视器下的互斥

当一个线程到达了一个监视区域的开始处,它就会被放置到该监视器的入口区。如果没有其他线程在入口区等待,也没有线程持有该监视器,则这个线程就可以获得监视器,并继续执行监视区域中的代码。当这个线程执行完监视区域后,它就会退出(并释放)该监视器。

如果一个线程到达了一个一个监视区域的开始处,犯这个监视区域已经有线程持有该监视器了,则这个刚刚到达的线程必须在入口区等待。当监视器的持有者退出监视器后,新到达的线程必须与其它已经在入口区等待的线程进行一次比赛,最终只会有一个线程获得监视器。

监视器下的协作

当一个线程需要一些特别状态的数据,而由另一个线程负责改变这些数据的状态时,同步就显得特别重要。

举例:一个读线程会从缓冲区读取数据,而另一个写线程会向缓冲区填充数据。读线程需要缓冲区处于一个非空的状态,这样才可以从中读取数据,如果读线程发现缓冲区是空的,它就必须等待。写线程负责向缓冲区写数据,只有写线程写入完成,读线程才能相应的读取。

Java虚拟机使用的这种监视器被称作等待-唤醒监视器。在这种监视器中,在一个线程(方便区分,叫线程A)持有监视器的情况下,可以通过执行一个等待命令,暂停自身的执行。
线程A执行了等待命令后,它就会释放监视器,并进入一个等待区,这个线程A会一直持续暂停状态,直到一段时间后,这个监视器中的其他线程执行了唤醒命令
当一个线程(线程B)执行了唤醒命令后,它会继续持有监视器,直到他主动释放监视器(执行完监视区域或执行一个等待命令)。当执行唤醒的线程(线程B)释放了监视器后,等待线程(线程A)会苏醒,并重新获得监视器。

等待-唤醒监视器有时也被称作发信号并继续(这个翻译没谁了。。。。)监视器,究其原因,就是在一个线程执行唤醒操作后,它还会继续持有监视器并继续执行监视区域,过了段时间之后,唤醒线程释放监视器,等待线程才会苏醒。

所以一次唤醒往往会被等待线程看作是一次提醒,告诉它“数据已经是你想要的状态了”。当等待线程苏醒后,它需要再次检查状态,以确认是否可以继续完成工作,如果数据不是它所需要的状态,等待线程可能会再次执行等待命令或者放弃等待退出监视器

还是上面的例子:一个读线程、一个缓冲区、一个写线程。假定缓冲区是由某个监视器所保护的,当读线程进入这个监视器时,它会检查缓冲区是否为空:

  • 如果不为空,读线程会从中取出一些数据,然后退出监视器。
  • 如果是空的,读线程会执行一个等待命令,同时它会暂停执行并进入等待区

这样读线程释放了监视器,让其他线程有机会可以进入。稍后,写线程进入了监视器,向缓冲区写入了一些数据,然后执行唤醒,并退出监视器。当写线程执行了唤醒指令后,读线程被标志为可能苏醒,当写线程退出监视器后,读线程被唤醒并成为监视器的持有者。

监视器模型

Java虚拟机中的监视器模型分成了三个区域。如下图:

image
虚拟机将监视器分为三个区域:

  • 中间大的监视区域只允许一个单独的线程,是监视器的持有者;
  • 左边是入口区
  • 右边是等待区

等待线程和活动线程使用红色和蓝色区分。

模型中也规定了线程和监视器交互所必须通过的几道门:

  • 当一个线程到达监视区域的开始处时,它会从最左边1号箭头进入入口区,当进入入口区
    • 如果没有任何线程持有监视器,也没有任何等待的线程,这个线程就可以通过2号箭头,并持有监视器。作为监视器的持有者,它可以继续执行监视区域中的代码。
    • 如果已经有另一个线程正在持有监视器,这个新到达的线程必须在入口区等待,很可能已经有线程已经在等待了,并且这个新线程会被阻塞,不能执行监视区域中的代码。
  • 上图中有三个线程在等待区中,这些线程会一直在那里,直到监视区域中的活动线程释放监视器
  • 活动线程会通过两条途径释放监视器:
    • 如果活动线程执行完了监视区域的代码,它会从5号箭头退出监视器。
    • 如果活动线程执行了等待命令,它会通过3号箭头进入等待区,并释放监视器
  • 如果活动线程在释放监视器前没有执行唤醒命令(同时在此之前没有任何等待区的线程被唤醒并等待苏醒),那么位于入口区的线程们将会竞争获得监视器。
  • 如果活动线程在释放监视器前执行了唤醒命令,入口区的线程就不得不和等待区的线程一起来竞争:
    • 如果入口区的线程获胜,它就会通过2号箭头进入监视区域,并获得监视器
    • 如果等待区的线程获胜,它会通过4号箭头退出等待区并重新获得监视器。

请注意,==一个线程只有通过3号箭头4号箭头才能进入或退出等待区。并且一个线程只有在它持有监视器的时候才能执行等待命令,而且它只能通过再次成为监视器的持有者才能离开等待区。==

线程唤醒的一些细节

在Java虚拟机中,线程在执行等待命令时可以随意指定一个暂停之间。在暂停时间到了之后,即使没有来自其他线程的明确的唤醒命令,它也会自动苏醒。看下面这段代码:

public class MonitorTest {
    public static void main(String[] args) {
        byte[] buffer = new byte[4];
        MonitorObj monitorObj = new MonitorObj();

        Thread read00 = new Thread() {
            @Override
            public void run() {
                System.out.println("read00 准备获取锁");
                synchronized (monitorObj) {
                    System.out.println("read00 = " + buffer[3]);
                    try {
                        Thread.sleep(1000);

                        monitorObj.wait();
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                    System.out.println("read00 = " + buffer[3]);
                }
                System.out.println("read00 释放锁");
            }
        };
        Thread read01 = new Thread() {
            @Override
            public void run() {
                System.out.println("read01 准备获取锁");
                synchronized (monitorObj) {
                    System.out.println("read01 = " + buffer[3]);
                    try {
                        Thread.sleep(1000);

                        monitorObj.wait(2000);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                    System.out.println("read01 = " + buffer[3]);
                }
                System.out.println("read01 释放锁");
            }
        };

        Thread write = new Thread() {
            @Override
            public void run() {
                System.out.println("write 准备获取锁");
                synchronized (monitorObj) {
                    try {
                        Thread.sleep(5000);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                    buffer[3] = 99;
                    //monitorObj.notifyAll();
                    try {
                        Thread.sleep(3000);
                        System.out.println("write thread finish");
                        Thread.sleep(1000);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }
            }
        };

        read00.start();
        read01.start();
        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        write.start();

    }
}
class MonitorObj {
}
复制代码

请注意,read01线程使用的是wait(2000)方法;read00线程使用的是wait()方法。

然后,我们把write线程的monitorObj.notifyAll()唤醒方法注释掉,输出如下:

read00 准备获取锁
read01 准备获取锁
read00 = 0
read01 = 0
write 准备获取锁
write thread finish
read01 = 99
read01 释放锁
复制代码

因为wait(2000)加了暂停时间的原因,read01还是自动唤醒了。而对于read00仍然并且会一直处于等待,除非调用唤醒指令notify()notifyAll()

而对于notify()notifyAll()的使用,请注意==只有当绝对确认只会有一个线程在等待区中挂起的时候,才可以使用notifynotifyAll也可以);只要同时存在有多个线程在等待区中被挂起,就应该使用notifyAll==

对象锁

前面讲过,Java虚拟机的一些运行时数据区会被所有线程共享,像方法区。所以,Java程序需要为这两种多线程下的数据访问进行协调:

  • 保存在堆中的实例变量
  • 保存在方法区中的类变量

程序不需要考虑Java栈中的局部变量,因为是线程私有的。

在Java虚拟机中,每个对象和类在逻辑上都有一个监控器与之相关联的。

  • 对于对象来说,相关联的监视器保护对象的实例变量。
  • 对于类来说,监视器保护它的类变量。

如果一个对象没有实例变量,或者一个类没有类变量,相关联的监视器就什么都不监视。

为了实现监视器的排他性监视能力,Java虚拟机为每一个对象和类都关联了一个锁(有时候被称为互斥体mutex)。一个锁就像就像一种任何时候只允许一个线程拥有的特权。

  • 正常情况下,线程访问实例变量或者类变量不需要获取锁。
  • 但是如果线程获取了锁,那么在它释放这个锁之前,就没有其他线程可以获取这个锁了。

锁住一个对象,其实就是获取对象相关联的监视器。而类锁实际上也是用对象锁来实现的。我们前面说过,当虚拟机装载一个class文件时,它会创建一个java.lang.Class类的实例。当锁住一个类时,实际上锁住的的就是那个类的Class对象。

一个线程可以允许多次对同一个对象上锁(可重入)。对于每一个对象来说,Java虚拟机维护了一个计数器,记录对象被加了多少次锁:

  • 没有被锁的对象的计数器是0
  • 线程每加锁一次,计数就加1(只有已经拥有了这个对象锁的线程才能对该对象再次加锁)
  • 线程每释放一次锁,计数器减1
  • 当计数器为0时,锁就被完全释放了。此时其他线程才可以使用它。

对象锁和监视器

==监视器能够实现拦截线程,保证监视区域只有一个线程在工作。靠的就是对象锁==

在Java虚拟机中,每一个监视区域都和一个对象引用相关联。所以整个流程差不多是这样子的:

  • Java虚拟机中的一个线程进入监视器入口区
  • 线程根据监视区域的对象引用,找到对应的数据
    • 如果数据显示计数器数值为0,表示监视区域没有活动线程,可以(多个线程的话需要竞争)加锁并通过2号箭头进入监视区域,执行后续代码。
    • 如果数值不为0,那么表示监视区域正在被占用,线程就要在入口区等待,等待锁的数值变为0,和其他线程(如果有的话)竞争进入
    • 当线程离开监视区域后,不管是如何离开的,它都会释放相关对象上的锁。

虚拟机对于监视区域的处理

==怎么定义上面提到的监视区域呢?==

Java中的关键字synchronized就是用来定义监视区域的关键。synchronized可以用来定义同步语句同步方法

同步语句

synchronized包裹起来的代码块就是同步语句,像下面这样:

public class SynchronizeTest {
    private int[] array = new int[]{1, 2, 3, 4};

    public void expandArray() {
        synchronized (this) {
            for (int i = 0; i < array.length; i++) {
                array[i] = array[i] * 10;
            }
            System.out.println(Arrays.toString(array));
        }
    }
}
复制代码

对于上面的同步代码块来说,虚拟机要保证不管线程以什么样的形式退出,必须要及时释放锁。

==怎么保证呢?==

假如上面的代码array[i] = array[i] * 10;不小心写成了array[i] = array[i]/0;,当执行到这一步的时候就要报java.lang.ArithmeticException异常了。
对于可能抛出的异常来说,我们会使用try-catch进行捕获,编译器的做法也是一样的。

我们看下javap -p SynchronizeTest.classexpandArray()部分输出:

  public void expandArray();
    descriptor: ()V
    flags: ACC_PUBLIC
    Code:
      stack=4, locals=4, args_size=1
         0: aload_0
         1: dup
         2: astore_1
         3: monitorenter
         4: iconst_0
         5: istore_2
         6: iload_2
         7: aload_0
         8: getfield      #2                  // Field array:[I
        11: arraylength
        12: if_icmpge     35
        15: aload_0
        16: getfield      #2                  // Field array:[I
        19: iload_2
        20: aload_0
        21: getfield      #2                  // Field array:[I
        24: iload_2
        25: iaload
        26: iconst_0
        27: idiv
        28: iastore
        29: iinc          2, 1
        32: goto          6
        35: getstatic     #3                  // Field java/lang/System.out:Ljava/io/PrintStream;
        38: aload_0
        39: getfield      #2                  // Field array:[I
        42: invokestatic  #4                  // Method java/util/Arrays.toString:([I)Ljava/lang/String;
        45: invokevirtual #5                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
        48: aload_1
        49: monitorexit
        50: goto          58
        53: astore_3
        54: aload_1
        55: monitorexit
        56: aload_3
        57: athrow
        58: return
      Exception table:
         from    to  target type
             4    50    53   any
            53    56    53   any
复制代码

请注意Exception table这个异常表,这个就是编译器细心为我们加上的。它会监听从方法的第4条指令第50条指令执行过程中的any异常,出现异常就跳到第53条指令

我们可以看到53往后还有一个monitorexit在等待执行(这个any说明啥异常也阻止不了释放锁的决心啊)。

==是不是感觉编译器真滴很贴心哇,赞!==

如果觉得不真实的话我们把synchronized代码块去掉,再编译一次看下字节码信息,你会发现Exception table也被清除了。

同步方法

还是上面的类SynchronizeTest,这次我们把方法改成这样:

    public synchronized void expandArray() {
        for (int i = 0; i < array.length; i++) {
            array[i] = array[i] / 0;
        }
        System.out.println(Arrays.toString(array));
    }
复制代码

我们再来看下相关的字节码:

  public synchronized void expandArray();
    descriptor: ()V
    flags: ACC_PUBLIC, ACC_SYNCHRONIZED
    Code:
      stack=4, locals=2, args_size=1
         0: iconst_0
         1: istore_1
         2: iload_1
         3: aload_0
         4: getfield      #2                  // Field array:[I
         7: arraylength
         8: if_icmpge     31
        11: aload_0
        12: getfield      #2                  // Field array:[I
        15: iload_1
        16: aload_0
        17: getfield      #2                  // Field array:[I
        20: iload_1
        21: iaload
        22: iconst_0
        23: idiv
        24: iastore
        25: iinc          1, 1
        28: goto          2
        31: getstatic     #3                  // Field java/lang/System.out:Ljava/io/PrintStream;
        34: aload_0
        35: getfield      #2                  // Field array:[I
        38: invokestatic  #4                  // Method java/util/Arrays.toString:([I)Ljava/lang/String;
        41: invokevirtual #5                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
        44: return
复制代码

我们看下区别(本人就找到了3条):

  • monitorentermonitorexit都不见了。
  • 多了一个ACC_SYNCHRONIZEDflag
  • 异常表也不见了(真滴不是故意没粘贴哈)

由于编译器在同步语句里表现那么好,我来揣测一下它这么做的理由哈?

  • 核心是ACC_SYNCHRONIZED的标记
  • 当虚拟机解析到对这个方法的符号引用时,它会判断这个方法是否是同步的(根据ACC_SYNCHRONIZED标记)
  • 如果是同步的,虚拟机就在调用方法前获取一个锁。
    • 对于实例对象来说虚拟机获取的是与当前对象相关联的锁
    • 对于类方法来说,虚拟机获取的是类Class实例相关联的锁
  • 当方法执行完毕时(不管是异常终止还是正常退出),虚拟机都会释放这个锁

简单比较下同步方法和同步语句

从字节码指令上来看:

  • 同步方法没有monitorentermotorexit等指令
  • 同步方法没有异常表
  • 同步方法少了一些变量保存的指令(用来记录对象锁的)

同步方法字节码更简洁,看上去更高效一些。

==但真的是这样吗?==

Amdahl 定律了解一下

speed=\frac{1}{F+\frac{1-F}{N}}
复制代码

N 表示处理器,F 表示必须串行的部分

当N趋近于无穷大时,

speed = \frac{1}{F}
复制代码

你懂得。。

Object的协调支持

Object一些方法我们前面已经用过了,统一整理一下。下次让你介绍Object中定义的方法就可以把下面这几个说一下了:

方法 描述
void wait() 进入监视器的等待区,直到被其他线程唤醒
void wait(long timeout) 进入监视器的等待区,直到被其他线程唤醒。或者经过timeout指定的毫秒后,自动苏醒
void wait(long timeout,int nanos) 进入监视器的等待区,直到被其他线程唤醒。或者经过timeout指定的毫秒加上nanos指定的纳秒后,自动苏醒
void notify() 唤醒监视器的等待区中的一个等待线程(如果等待区中没有线程,那就什么也不敢)
void notifyAll() 唤醒监视器的等待区中的所有等待线程(如果等待区中没有线程,那就什么也不敢)

==上面的5个方法,请在同步语句或同步方法中使用==,不然会报错哟!
就是这种java.lang.IllegalMonitorStateException

附上Object类的代码:

public class Object {

    private static native void registerNatives();
    static {
        registerNatives();
    }
    
    public final native Class<?> getClass();

    public native int hashCode();

    public boolean equals(Object obj) {
        return (this == obj);
    }

    protected native Object clone() throws CloneNotSupportedException;

    public String toString() {
        return getClass().getName() + "@" + Integer.toHexString(hashCode());
    }

    public final native void notify();

    public final native void notifyAll();

    public final native void wait(long timeout) throws InterruptedException;

    public final void wait(long timeout, int nanos) throws InterruptedException {
        if (timeout < 0) {
            throw new IllegalArgumentException("timeout value is negative");
        }

        if (nanos < 0 || nanos > 999999) {
            throw new IllegalArgumentException(
                                "nanosecond timeout value out of range");
        }

        if (nanos > 0) {
            timeout++;
        }

        wait(timeout);
    }

    public final void wait() throws InterruptedException {
        wait(0);
    }

    protected void finalize() throws Throwable { }
}

复制代码

很简洁有没有。。。。。。

==有个疑问哈,这么多native方法,咋没看到在哪里加载的lib呢?==
真滴是个疑问,权当挖个坑,他日必来回复。。。。。

结语

好滴,到这里虚拟机的线程同步就结束啦,深入Java虚拟机也到了尾声,收获很多。下一篇好好总结一下吧。