本文介绍用 synchronized 实现 Java 线程安全的方式和一些使用方法,synchronized 是 Java 提供多线程通信最基本的一种机制,出现的比 ReentrantLock 早,它是使用监视器(monitor)来实现。
Java 每个对象都关联了一个监视器,线程可以对其进行加锁和解锁操作。在同一时间,只有一个线程可以拿到对象上的监视器锁。如果其他线程在锁被占用期间试图去获取锁,那么将会被阻塞直到成功获取到锁。同时,监视器锁可以重入,也就是说如果线程 t 拿到了锁,那么线程 t 可以在解锁之前重复获取锁;每次解锁操作会反转一次加锁产生的效果。
监视器 monitor 的解释如下:
每个对象有一个监视器锁(monitor)。当 monitor 被占用时就会处于锁定状态,线程执行 monitorenter 指令时尝试获取 monitor 的所有权,过程如下:
synchronized 有以下两种使用方式:
Java 语音规范既不要求阻止死锁的发生,也不要求检测到死锁的发生,这是我们在使用时应该需要注意的一点。
等待操作必须在获取到监视器锁的情况下才可以调用,也就是说 synchronized 指定的对象和 调用 wait() 的对象必须是同一个。等待操作由以下几个操作引发:wait()、wait(long timeout)、wait(long timeout, int nanos)。功能上的区别:
通知操作由以下几个操作引发:nofity()、nofityAll()。功能上的区别:
中断操作由线程 u 的 u.interrupt() 调用,前面有讲过线程中断的介绍,线程中断不会使线程挂起,只是对线程做一个标记。以下线程在阻塞状态的三种情况,会立即自动感知到中断状态,并将中断状态重新置为 false:
另外还有 LockSupport 的 park 方法也会自动感知线程中断状态,但是它不会重置中断状态。
需要注意的是,即使中断状态被 wait 这些方法立即感知到,也不会马上抛出 InterruptedException 异常,也需要获取到监视器锁后才会抛出异常。
看一下简单的一个 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();
}
理解运行结果需要先知道这两个前提:
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.
所以运行的结果会三种情况:
线程2唤醒
线程1异常
false
java.lang.InterruptedException
线程1异常
false
java.lang.InterruptedException
线程2唤醒
线程1唤醒
true
总结:
不允许发生指令重排,一定会是先打断再唤醒。前面说过即使发生中断也需要获取锁后再抛异常,那么唤醒时,在线程 1 已经发生中断的情况下,会选择其他正在等待的线程进行唤醒,最后被中断的线程才获取到锁抛出异常。所以会出现第一种和第二种结果,但是第二种结果出现的会极少。
注释掉 Thread.sleep(3000),允许指令重排,但是没有出现指令重排的情况也是会出现第一种和第二种结果,但是这次是第二种结果出现的居多。
注释掉 Thread.sleep(3000),发生了指令重排后,会出现第三种情况,也就是说,线程 1 在中断前就被唤醒了,但是保留了中断状态,线程 2 得不到唤醒。
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 以后,为了减少获得锁和释放锁所带来的性能消耗,提高性能,引入了“轻量级锁”和“偏向锁”。
轻量级锁
“轻量级”是相对于使用操作系统互斥量来实现的传统锁而言的。但是,首先需要强调一点的是,轻量级锁并不是用来代替重量级锁的,它的本意是在没有多线程竞争的前提下,减少传统的重量级锁使用产生的性能消耗。在解释轻量级锁的执行过程之前,先明白一点,轻量级锁所适应的场景是线程交替执行同步块的情况,如果存在同一时间访问同一锁的情况,就会导致轻量级锁膨胀为重量级锁。
轻量级锁的加锁过程:
轻量级锁的解锁过程:
偏向锁
引入偏向锁是为了在无多线程竞争的情况下尽量减少不必要的轻量级锁执行路径,因为轻量级锁的获取及释放依赖多次 CAS 原子指令,而偏向锁只需要在置换 ThreadID 的时候依赖一次 CAS 原子指令(由于一旦出现多线程竞争的情况就必须撤销偏向锁,所以偏向锁的撤销操作的性能损耗必须小于节省下来的 CAS 原子指令的性能消耗)。上面说过,轻量级锁是为了在线程交替执行同步块时提高性能,而偏向锁则是在只有一个线程执行同步块时进一步提高性能。
偏向锁的加锁过程:
偏向锁的解锁过程:
重量级锁、轻量级锁和偏向锁之间转换
锁 | 优点 | 缺点 | 适用场景 | 效率 |
---|---|---|---|---|
偏向锁 | 加锁和解锁不需要额外的消耗,和执行非同步方法比仅存在纳秒级的差距 | 如果线程间存在锁竞争,会带来额外的锁撤销的消耗 | 适用于只有一个线程访问同步块场景 | |
轻量级锁 | 竞争的线程不会阻塞,提高了程序的响应速度 | 如果始终得不到锁竞争的线程使用自旋会消耗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 空闲时间,超过核心线程数并且也超过了空闲时间,就会开始回收关掉线程。当线程池中的线程数量没有超过核心线程数,会创建新的线程来执行任务,当达到核心线程数会将任务添加到等待队列中,等待线程池去从等待队列中取任务;如果队列已满,就需要创建新的线程执行,但是不能超过最大线程数,超过了执行拒绝策略。