Java并发------Synchronized使用

前言

本文介绍用 synchronized 实现 Java 线程安全的方式和一些使用方法,synchronized 是 Java 提供多线程通信最基本的一种机制,出现的比 ReentrantLock 早,它是使用监视器(monitor)来实现。

Java 每个对象都关联了一个监视器,线程可以对其进行加锁和解锁操作。在同一时间,只有一个线程可以拿到对象上的监视器锁。如果其他线程在锁被占用期间试图去获取锁,那么将会被阻塞直到成功获取到锁。同时,监视器锁可以重入,也就是说如果线程 t 拿到了锁,那么线程 t 可以在解锁之前重复获取锁;每次解锁操作会反转一次加锁产生的效果。

监视器 monitor 的解释如下:

每个对象有一个监视器锁(monitor)。当 monitor 被占用时就会处于锁定状态,线程执行 monitorenter 指令时尝试获取 monitor 的所有权,过程如下:

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

synchronized 有以下两种使用方式:

  • 代码块:synchronized 作用于代码块上需要指定对象,这个对象相应的监视器就会进行加锁操作;
  • 方法:synchronized 作用于方法也叫同步方法。如果修饰的方法是实例方法,那么会对这个实例的监视器进行加锁操作;如果修饰的是静态方法,那么会对 Class 对象的监视器进行加锁操作。这两种加锁不构成同步。

Java 语音规范既不要求阻止死锁的发生,也不要求检测到死锁的发生,这是我们在使用时应该需要注意的一点。

一、等待(Wait)

等待操作必须在获取到监视器锁的情况下才可以调用,也就是说 synchronized 指定的对象和 调用 wait() 的对象必须是同一个。等待操作由以下几个操作引发:wait()、wait(long timeout)、wait(long timeout, int nanos)。功能上的区别:

  • wait() 必须由 notify() 和 notifyAll() 来唤醒,如果是 notifyAll(),则只需要唤醒一次,其他所有正在等待的线程会依次获取到锁后被唤醒;如果是 nofity(),只会唤醒一个正在等待的线程。
  • wait(long timeout) 可以不需要 notify() 和 notifyAll() 来唤醒,时间到了会自动醒来,但是前提是需要获取到锁之后才会执行。
  • 线程 t 中断提前发生会导致线程 t 提前从 wait 方法返回,并抛出 InterruptedException 异常,重置中断状态。

二、通知(Notification)

通知操作由以下几个操作引发:nofity()、nofityAll()。功能上的区别:

  • notify() 调用后,从等待集合中选中一条线程进行唤醒,JVM 不保证哪条线程会被唤醒,被唤醒的线程重新持有监视器锁。
  • notifyAll() 调用后,所有等待集合的线程都会被移出,依次获取监视器锁后执行。

三、中断(Interruptions)

中断操作由线程 u 的 u.interrupt() 调用,前面有讲过线程中断的介绍,线程中断不会使线程挂起,只是对线程做一个标记。以下线程在阻塞状态的三种情况,会立即自动感知到中断状态,并将中断状态重新置为 false:

  1. wait()、wait(long)、wait(long, int)、join()、join(long)、join(long, int)、sleep(long)、sleep(long, int),这些方法签名上都有 throws InterruptedException,用来立即捕获线程中断状态;
  2. 如果线程阻塞在 InterruptibleChannel 类的 IO 操作中,那么这个 channel 会被关闭。
  3. 如果线程阻塞在一个 Selector 中,那么 select 方法会立即返回。

另外还有 LockSupport 的 park 方法也会自动感知线程中断状态,但是它不会重置中断状态。

需要注意的是,即使中断状态被 wait 这些方法立即感知到,也不会马上抛出 InterruptedException 异常,也需要获取到监视器锁后才会抛出异常。

四、等待、通知和中断 的交互(Interactions of Waits, Notification, and Interruption)

看一下简单的一个 demo :

public static void main(String[] args) {
    final Thread[] thread = {null};
    Object object = new Object();
    new Thread(() -> {
        synchronized (object) {
            try {
                thread[0] = Thread.currentThread();
                object.wait();
                System.out.println(Thread.currentThread().getName() + "唤醒");
                System.out.println(Thread.currentThread().isInterrupted());
            } catch (InterruptedException e) {
                System.out.println(Thread.currentThread().getName() + "异常");
                System.out.println(Thread.currentThread().isInterrupted());
                e.printStackTrace();
            }
        }
    }, "线程1").start();
    new Thread(() -> {
        synchronized (object) {
            try {
                object.wait();
                System.out.println(Thread.currentThread().getName() + "唤醒");
            } catch (InterruptedException e) {
                System.out.println(Thread.currentThread().getName() + "异常");
                e.printStackTrace();
            }
        }
    }, "线程2").start();
    new Thread(() -> {
        synchronized (object) {
            try {
                thread[0].interrupt();
                Thread.sleep(3000);//作用是禁止上下两行指令重排
                //System.out.println(333333);
                object.notify();
            } catch (Exception e) {
                e.printStackTrace();
            }
        }
    }, "线程3").start();
}

理解运行结果需要先知道这两个前提:

  1. 线程 t 在 m 的等待集中,t 的中断和 m 的唤醒同时发生,则必须对这两个事件进行排序。如果认为中断首先发生,则 t 最终将通过抛出 InterruptedException 从等待返回,并且 m 的等待集中的一些其他线程(如果在唤醒时有其他在等待的线程)必须接收通知。如果唤醒被认为是先发生,那么 t 最终将从等待正常返回,并且中断仍然未决。

For example, if a thread t is in the wait set for m, and then both an interrupt of t and a notification of m occur, there must be an order over these events. If the interrupt is deemed to have occurred first, then t will eventually return from wait by throwing InterruptedException, and some other thread in the wait set for m (if any exist at the time of the notification) must receive the notification. If the notification is deemed to have occurred first, then t will eventually return normally from wait with an interrupt still pending.

  1. 指令重排

所以运行的结果会三种情况:

线程2唤醒
线程1异常
false
java.lang.InterruptedException

线程1异常
false
java.lang.InterruptedException
线程2唤醒

线程1唤醒
true

总结:

  1. 不允许发生指令重排,一定会是先打断再唤醒。前面说过即使发生中断也需要获取锁后再抛异常,那么唤醒时,在线程 1 已经发生中断的情况下,会选择其他正在等待的线程进行唤醒,最后被中断的线程才获取到锁抛出异常。所以会出现第一种和第二种结果,但是第二种结果出现的会极少。

  2. 注释掉 Thread.sleep(3000),允许指令重排,但是没有出现指令重排的情况也是会出现第一种和第二种结果,但是这次是第二种结果出现的居多。

  3. 注释掉 Thread.sleep(3000),发生了指令重排后,会出现第三种情况,也就是说,线程 1 在中断前就被唤醒了,但是保留了中断状态,线程 2 得不到唤醒。

五、休眠和礼让(Sleep and Yield)

Thread.sleep(millisecs) 使当前线程休眠指定的时间,在休眠期间内,不会释放任何的监视器锁,休眠后恢复的时间精度受制于系统的定时器。

Thread.yield() 是告诉 cpu 可以礼让调度器给其他线程,但是调度器可以不理会这个信息,没有太多的使用价值。

Thread.sleep 和 Thread.yield 的机制:调用前不要求虚拟机将本地内存的内容刷到主内存中,调用后也不要求主内存的内容刷到缓存中。看下面例子:

while (!this.b)
    Thread.sleep(1000);

当前线程的寄存器只会读取一次 b 的结果到本地内存中,然后一直使用本地内存中的这个值,所以这个循环可能永远不会结束,即使其他线程已经修改了这个值。

六、扩展

重量级锁

Synchronized 是通过对象内部的一个叫做监视器锁(monitor)来实现的。但是监视器锁本质又是依赖于底层的操作系统的 Mutex Lock 来实现的。而操作系统实现线程之间的切换这就需要从用户态转换到核心态,这个成本非常高,状态之间的转换需要相对比较长的时间,这就是为什么 Synchronized 效率低的原因。因此,这种依赖于操作系统 Mutex Lock 所实现的锁我们称之为“重量级锁”。JDK 中对 Synchronized 做的种种优化,其核心都是为了减少这种重量级锁的使用。JDK1.6 以后,为了减少获得锁和释放锁所带来的性能消耗,提高性能,引入了“轻量级锁”和“偏向锁”。
Java并发------Synchronized使用_第1张图片
轻量级锁

“轻量级”是相对于使用操作系统互斥量来实现的传统锁而言的。但是,首先需要强调一点的是,轻量级锁并不是用来代替重量级锁的,它的本意是在没有多线程竞争的前提下,减少传统的重量级锁使用产生的性能消耗。在解释轻量级锁的执行过程之前,先明白一点,轻量级锁所适应的场景是线程交替执行同步块的情况,如果存在同一时间访问同一锁的情况,就会导致轻量级锁膨胀为重量级锁。

轻量级锁的加锁过程:

  1. 在代码进入同步块的时候,如果同步对象锁状态为无锁状态(锁标志位为 “01” 状态,是否为偏向锁为 “0”),虚拟机首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的 Mark Word 的拷贝,官方称之为 Displaced Mark Word。
  2. 拷贝对象头中的 Mark Word 复制到锁记录中。
  3. 拷贝成功后,虚拟机将使用 CAS 操作尝试将对象的 Mark Word 更新为指向 Lock Record 的指针,并将 Lock record 里的 owner 指针指向 object mark word。如果更新成功,则执行步骤(4),否则执行步骤(5)。
  4. 如果这个更新动作成功了,那么这个线程就拥有了该对象的锁,并且对象 Mark Word 的锁标志位设置为 “00”,即表示此对象处于轻量级锁定状态。
  5. 如果这个更新操作失败了,虚拟机首先会检查对象的 Mark Word 是否指向当前线程的栈帧,如果是就说明当前线程已经拥有了这个对象的锁,那就可以直接进入同步块继续执行。否则说明多个线程竞争锁,轻量级锁就要膨胀为重量级锁,锁标志的状态值变为 “10”,Mark Word 中存储的就是指向重量级锁(互斥量)的指针,后面等待锁的线程也要进入阻塞状态。 而当前线程便尝试使用自旋来获取锁,自旋就是为了不让线程阻塞,而采用循环去获取锁的过程。

轻量级锁的解锁过程:

  1. 通过 CAS 操作尝试把线程中复制的 Displaced Mark Word 对象替换当前的 Mark Word。
  2. 如果替换成功,整个同步过程就完成了。
  3. 如果替换失败,说明有其他线程尝试过获取该锁(此时锁已膨胀),那就要在释放锁的同时,唤醒被挂起的线程。

偏向锁

引入偏向锁是为了在无多线程竞争的情况下尽量减少不必要的轻量级锁执行路径,因为轻量级锁的获取及释放依赖多次 CAS 原子指令,而偏向锁只需要在置换 ThreadID 的时候依赖一次 CAS 原子指令(由于一旦出现多线程竞争的情况就必须撤销偏向锁,所以偏向锁的撤销操作的性能损耗必须小于节省下来的 CAS 原子指令的性能消耗)。上面说过,轻量级锁是为了在线程交替执行同步块时提高性能,而偏向锁则是在只有一个线程执行同步块时进一步提高性能。

偏向锁的加锁过程:

  1. 访问 Mark Word 中偏向锁的标识是否设置成 1,锁标志位是否为 01——确认为可偏向状态。
  2. 如果为可偏向状态,则测试线程 ID 是否指向当前线程,如果是,进入步骤(5),否则进入步骤(3)。
  3. 如果线程 ID 并未指向当前线程,则通过 CAS 操作竞争锁。如果竞争成功,则将 Mark Word 中线程 ID 设置为当前线程 ID,然后执行(5);如果竞争失败,执行(4)。
  4. 如果 CAS 获取偏向锁失败,则表示有竞争。当到达全局安全点(safepoint)时获得偏向锁的线程被挂起,偏向锁升级为轻量级锁,然后被阻塞在安全点的线程继续往下执行同步代码。
  5. 执行同步代码。

偏向锁的解锁过程:

  • 偏向锁的撤销在上述第四步骤中有提到。偏向锁只有遇到其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁,线程不会主动去释放偏向锁。偏向锁的撤销,需要等待全局安全点(在这个时间点上没有字节码正在执行),它会首先暂停拥有偏向锁的线程,判断锁对象是否处于被锁定状态,撤销偏向锁后恢复到未锁定(标志位为“01”)或轻量级锁(标志位为“00”)的状态。

重量级锁、轻量级锁和偏向锁之间转换

优点 缺点 适用场景 效率
偏向锁 加锁和解锁不需要额外的消耗,和执行非同步方法比仅存在纳秒级的差距 如果线程间存在锁竞争,会带来额外的锁撤销的消耗 适用于只有一个线程访问同步块场景
轻量级锁 竞争的线程不会阻塞,提高了程序的响应速度 如果始终得不到锁竞争的线程使用自旋会消耗CPU 追求响应时间 同步块执行速度非常快
重量级锁 线程竞争不使用自旋,不会消耗CPU 线程阻塞,响应时间缓慢 追求吞吐量 同步块执行速度较长

其他优化

1、适应性自旋(Adaptive Spinning):

从轻量级锁获取的流程中我们知道,当线程在获取轻量级锁的过程中执行 CAS 操作失败时,是要通过自旋来获取重量级锁的。问题在于,自旋是需要消耗 CPU 的,如果一直获取不到锁的话,那该线程就一直处在自旋状态,白白浪费 CPU 资源。解决这个问题最简单的办法就是指定自旋的次数,例如让其循环 10 次,如果还没获取到锁就进入阻塞状态。但是 JDK 采用了更聪明的方式——适应性自旋,简单来说就是线程如果自旋成功了,则下次自旋的次数会更多,如果自旋失败了,则自旋的次数就会减少。

2、锁粗化(Lock Coarsening):

锁粗化的概念应该比较好理解,就是将多次连接在一起的加锁、解锁操作合并为一次,将多个连续的锁扩展成一个范围更大的锁。举个例子:

public class StringBufferTest1 {
     StringBuffer stringBuffer = new StringBuffer();
     public void append(){
         stringBuffer.append("a");
         stringBuffer.append("b");
         stringBuffer.append("c");
     }
 }

这里每次调用 stringBuffer.append 方法都需要加锁和解锁,如果虚拟机检测到有一系列连串的对同一个对象加锁和解锁操作,就会将其合并成一次范围更大的加锁和解锁操作,即在第一次 append 方法时进行加锁,最后一次 append 方法结束后进行解锁。

3、锁消除(Lock Elimination):

锁消除即删除不必要的加锁操作。根据代码逃逸技术,如果判断到一段代码中,堆上的数据不会逃逸出当前线程,那么可以认为这段代码是线程安全的,不必要加锁。看下面这段程序:

public class SynchronizedTest {
    public static void main(String[] args) {
        SynchronizedTest demo = new SynchronizedTest();
        //启动预热
        for (int i = 0; i < 10000; i++) {
            i++;
        }
        long start = System.currentTimeMillis();
        for (int i = 0; i < 100000000; i++) {
            demo.append("abc", "def");
        }
        System.out.println("Time=" + (System.currentTimeMillis() - start));
    }
    public void append(String str1, String str2) {
        StringBuffer sb = new StringBuffer();
        sb.append(str1).append(str2);
    }
}

虽然 StringBuffer 的 append 是一个同步方法,但是这段程序中的 StringBuffer 属于一个局部变量,并且不会从该方法中逃逸出去,所以其实这过程是线程安全的,可以将锁消除。下面是我本地执行的结果:

为了尽量减少其他因素的影响,这里禁用了偏向锁(-XX:-UseBiasedLocking)。通过上面程序,可以看出消除锁以后性能还是有比较大提升的。

六、总结:

1、说到 java 线程,先说一下 Synchronized,Synchronized 出现的版本比较早,他原理是使用对象的监视器锁,就是在同一时间只能有一条线程获取到对象的监视器锁,只有这条线程才有执行权,具体使用就是在代码块中使用需要指定对象,也就是获得这个对象的监视器锁,在方法中使用,静态方法是对 class 对象加锁,非静态方法是对实例对象加锁,两种加锁不构成同步。其他的使用方面,await 指定时间就是在指定时间后被唤醒,添加到等待队列中,不指定时间就必须要等待被唤醒,两个方法唤醒后都是需要获取到锁才会开始执行。notify 唤醒一条线程,notifyAll 一次唤醒所有正在等待的线程。interrupt 线程中断,意思不是中断线程的执行,而是给线程做一个中断的标记,这个标记会被一些特殊的方法立即感知到,await、notify、join 这些。为什么获取到监视器锁就能保证线程安全呢,首先获取到锁的线程才对对象、变量有修改的权利,另一方面就是内存可见性,Synchronized 的语义是在代码块中执行的代码会使本地内存失效,需要到主内存中去读取数据,在退出代码块后,会保证一定将这些变量重新刷入到主内存中,中间的操作过程不会保证。主内存和本地内存就是 jmm 屏蔽 cpu 的各个核心的一二级缓存而设置的模型,每个线程的寄存器就是本地内存,共享的就是主内存。说到内存可见性,有两个关键字比较重要,一个是 volatile,volatile 的作用是禁止指令重排和内存可见。什么是指令重排,就是上下两行无关的代码,比如 a=1,b=2,这是两条无关的赋值语句,jvm 的编译器或者 cpu 都有可能对这两条无关的指令做一个重排序优化。volatile 是怎么保证内存可见性的呢,volatile 的语义就是对于变量的读取都会先使本地内存失效,去从主内存读取,写入也是一样。volatile 并不局限于本身的这一条语句,上下的语句都会辐射到。所以说单例模式的双重检验锁模式为什么一定要使用 volatile,因为创建一个对象,首先会在内存中开辟一块空间,并初始化对象的属性值,使用 0 或者 null,然后再为这些属性值赋值,可能是常量池中的常量或是其他对象,反正都是一个地址值赋值给这个属性,最后再把这块空间的地址值赋值给这个对象。如果发生指令的重排序问题,比如上面三个步骤的第一步和第三步先发生,就是初始化空间和属性后直接将空间的地址值赋值给对象,这时候对象已经不是 null了,但是其实内部的属性值还没有完成赋值操作。另一个关键字就是 final,final 就是对象创建后不可更改,所以编译器会直接将创建并赋值完的对象从主内存中直接引入到本地内存中,不需要像其他属性都需要先从主内存中读取再存入本地内存,这是编译器做的一个优化,去除不必要的同步。final 在构造方法结束后才被认为初始化完成,这个过程是其他线程不可见的。

2、保证线程安全的另一个方式是 reentrantLock。说到 reentrantLock,先要了解 AQS,AQS 简单翻译就是抽象同步队列,他是实现 reentrantLock、condition 这些的基础。AQS 底层是使用 CAS 操作,即比较交换的方针,CAS 个人感觉是效率比较高的一种方式,jdk8 的 ConcurrentHashMap 就废弃了 jdk7 的策略,使用 CAS 操作的方式实现线程安全。AQS 的实现原理可以简单的理解为所有的线程对象都会被包装成 node,AQS 内部有一个阻塞队列的概念,是一个双向的链表结构,即当前有线程执行权也就是获取到锁的 node 对象存放在 head 中,其他等待的 node 都在阻塞队列中,队列的尾节点是 tail。每次新进来的线程的都会去尝试抢锁,抢到当前线程放入 head,没抢到,就会被放入阻塞队列中等待。放入的过程比较复杂,有一些自旋入对的方式,最终会将线程 park挂起。解锁就是将 head 后面的线程对象 unpark。AQS 分为公平锁和非公平锁两种,无非就是一种要去排队,一种可以插队。回过头说 reentrantLock,我们一般会与 condition 一起使用,condition 中又有一个条件队列的概念,他也是将线程对象的 node 放入条件队列中,其中也有很复杂的入队、挂起、唤醒的操作,唤醒后会 node 对象会被转到上面说的阻塞队列中等待获取锁来执行。另外除了 reentrantLock,还有共享模式的概念,有 CountDownLatch,CountDownLatch 设定规定的线程数后,所有的线程都会进入到等待状态,当线程数满了的时候,再一起放开执行。一起执行并不是都一起执行,cpu 也会分先后顺序,但是当所有线程都执行完了后才会返回给 CountDownLatch。还有 CyclicBarrier,CyclicBarrier 使用与 CountDownLatch 很相似,但是 CountDownLatch 只能使用一次,CyclicBarrier 可以使用多次,但是他们两个的实现方法却是截然不同的。还有 Semaphore,Semaphore 就简单的理解为停车场就可以,当满了情况下,必须有出来的才能进去。最后需要说的就是 BlockingQueue,BlockingQueue 继承了队列、集合,所有集合和队列的操作方法他也都有,但是他自己的两个方法 put 和 take 是最重要的,这两个方法才体现了线程安全。ArrayBlockingQueue 底层就是数组实现,由一个 reentrantLock 和两个 condition 实现。LinkedBlockingQueue 底层是双向链表结构,也是由一个 reentrantLock 和两个 condition 实现。还有一种特殊的 SynchronousQueue,他的内存是不提供存储元素的,单纯的写入和读取需要配合才能有返回。BlockingQueue 也是实现线程池的其中一个元素,线程池简单的说就是创建一个固定数量的线程,用于使用,其中有带返回值的和不带返回值的执行方式,当线程数超过核心线程数时,就会使用到 BlockingQueue 的策略了。线程池中的几个概念,核心线程数和最大线程数,这两个数字中间的线程会被回收关掉,执行队列 workqueue 存放任务,keepalivetime 空闲时间,超过核心线程数并且也超过了空闲时间,就会开始回收关掉线程。当线程池中的线程数量没有超过核心线程数,会创建新的线程来执行任务,当达到核心线程数会将任务添加到等待队列中,等待线程池去从等待队列中取任务;如果队列已满,就需要创建新的线程执行,但是不能超过最大线程数,超过了执行拒绝策略。

你可能感兴趣的:(Java基础,并发,线程)