JAVA并发(一)线程安全

线程安全的定义:当多个线程访问某个类时,不管运行时环境采用何种调度方式或者线程如何交替执行,主调代码中不需要任何额外的同步或协同,这个类都能表现出正确的行为,那么就称这个类为线程安全的。

原子性

原子性指的是一个操作可以作为一个不可分割的操作来执行。也指:对于一个线程正在使用的对象,使用过程中不会被其他线程修改。

1
2
3
public int autoIncrease(int i) {
return ++i;
}

++i或i++看上去是一个操作,但并非是原子操作,它包含了三个操作:

  • 读取i的值
  • 将值加1
  • 将计算结果写入i

这是一个操作序列:读取 - 修改 - 写入,其结果状态依赖于之前的状态。

两个线程同时执行该操作会导致线程不安全,因为两个线程可能会交替的执行上述三个操作,某一个线程读取到的值可能是无效的、过时的。

为了保证线程安全,需要确保两个线程按照顺序依次执行上述三个操作,即线程1执行完了三个操作后,线程2才能开始执行这三个操作。

在并发编程中,这种由于“不恰当”执行顺序而出现的不正确的结果称为:竞态条件。

“不恰当”的执行顺序是CPU优化导致的,是客观存在、有利于提高处理速度的,这种执行顺序总是存在的。程序员能掌控的是编写线程安全的代码,使其在竞态条件下也能满足线程安全。

竞态条件

最常见的竞态条件是:先检查后执行

下面的例子是一个懒加载单例类,它不是线程安全的:

1
2
3
4
5
6
7
8
9
10
11
public class LazyLoadSingleton {
private LazyLoadSingleton singleton = null;

public LazyLoadSingleton getSingleton() {
if (singleton == null) {
singleton = new LazyLoadSingleton();
}

return singleton;
}
}

线程A和线程B同时执行getSingleton。A看到singleton为空,因此创建一个新的LazyLoadSingleton实例;线程B同样要判断singleton是否为空,而B判断时singleton是否为空,取决于A执行到哪一步,即不可预测的执行顺序,可能会导致线程不安全。

避免竞态条件—复合操作

复合操作指将前文中的自增、先检查后执行的操作分别组合成原子操作,这样就能保证线程安全。

在java.util.concurrent.automic包中包含了一些原子变量类,用于实现在数值和对象引用的原子状态转换。比如自增的复合操作

1
2
3
4
5
6
7
public class AutoIncrease {
private final AtomicLong count = new AtomicLong(0);

public long increase() {
return count.incrementAndGet();
}
}

AtomicLong.incrementAndGet底层通过CAS实现了复合操作。

CAS

CAS相当于乐观锁,CAS对应了硬件指令CMPXCHG,该指令对应着”比较并交换的操作,如果一个值原来是A(预期值),修改为B,在CPU回写至内存时,会检查当前值是否为A(比较),如果为A,则将值更新为B(交换)。

CPU循环进行CAS操作直到成功为止。CAS虽然很高效的实现了原子性,但是CAS仍然存在三大问题:

  • ABA问题
  • 循环时间长开销大
  • 只能保证一个共享变量的原子操作。

ABA问题

CAS需要在操作值的时候检查下值有没有发生变化,如果没有发生变化则更新,但是如果一个值原来是A,变成了B,又变成了A,那么使用CAS进行检查时会发现它的值没有发生变化,但是实际上却变化了。

ABA问题的解决思路就是使用版本号。在变量前面追加上版本号,每次变量更新的时候把版本号加一,那么A-B-A 就会变成1A-2B-3A。 从Java1.5开始JDK的atomic包里提供了一个类AtomicStampedReference来解决ABA问题。

自旋时间长开销大

自旋CAS如果长时间不成功,会给CPU带来非常大的执行开销。如果JVM能支持处理器提供的pause指令那么效率会有一定的提升,pause指令有两个作用:

  • 延迟流水线执行指令(de-pipeline),使CPU不会消耗过多的执行资源。
  • 避免在退出循环时因内存顺序冲突而引起CPU流水线被清空,从而提高CPU的执行效率。

只能保证一个共享变量原子操作

对多个共享变量操作时,循环CAS就无法保证操作的原子性,有两种办法解决:

  • 用锁(下一节重点介绍)
  • 把多个共享变量合并成一个共享变量来操作。比如有两个共享变量i=2,j=a,合并一下ij=2a,然后用CAS来操作ij。从Java1.5开始JDK提供了类AtomicReference、AtomicStampedReference(解决ABA)来保证引用对象之间的原子性,可以把多个变量放在一个对象里来进行CAS操作。

加锁机制

加锁可以保证原子性,将多个操作复合为一组同步的操作,避免竞态条件。

将上述代码进行如下改动:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// 改动1
public class AutoIncrease {
private final AtomicLong count1 = new AtomicLong(0);
private final AtomicLong count2 = new AtomicLong(0);

public long increase() {
long num1 = count1.incrementAndGet();
long num2 = num1 + count2.incrementAndGet();
return num2;
}
}

// 改动2
public class AutoIncrease {
private final AtomicLong count = new AtomicLong(0);

public long increase() {
if (count.incrementAndGet() % 2 == 0) {
return count.get();
}
return count.get() + 1;
}
}

改动1中,方法increase中包含两个原子操作,但是increase方法的返回值num2,涉及到了多个变量:count1和count2,这两个变量之间不是独立的,而是某个变量的值会对其他变量的值进行约束。这就导致了increase整体成为了一个非原子的方法,不是线程安全的。

总结1:多个变量彼此不是相互独立时,不是原子操作

改动2中,先检查count自增后是否为为偶数,为偶数则直接返回,为奇数则加1再返回,这是典型的先检查后执行操作,increase整体是一个非原子方法,不是线程安全的。

一个操作是否是原子的,要看它包含的所有操作是否是

总结2:对于先检查后执行的操作,不是原子操作

对于改动1和改动2出现的非原子操作,JAVA提供了加锁机制,用来保证在一个原子操作中更新所有相关的状态变量,即将上述操作合并为一个整体,这种合并为一个整体是语言逻辑层面的,通过对象锁实现的,不是系统指令集层面的(CAS),锁的粒度是可以在写代码时掌控的。

对象锁

对象锁又称内置锁、Monitor锁,每一个Java对象自带了一把看不见的锁,通过synchronized关键字使用该锁。它可以保证原子性、有序性、可见性。正是因为如此强大,容易导致滥用。

synchronized的原理

synchronized的实现离不开Monitor。Monitor 是线程私有的数据结构,每一个线程都有一个可用monitor record列表,同时还有一个全局的可用列表。每一个被锁住的对象都会和一个monitor关联(对象头的MarkWord中的LockWord指向monitor的起始地址),Monitor包含了下列信息:

字段 含义
owner 占有该Monitor的线程的唯一标识,为Null时表示没有线程占用
EntryQ 关联一个系统互斥量(semaphore),阻塞所有试图锁住monitor record失败的线程
RcThis 被阻止的线程的个数
Nest 计数器,用来实现重入锁,没有线程持有monitor时该值为0
HashCode 与monitor关联的对象的hashcode
Candidate 只有两个值,0表示没有需要唤醒的线程,1表示要唤醒一个继任线程来竞争锁

以下面的代码为例:

1
2
3
4
5
public void synMethod() {
synchronized(this) {
// do smothing
}
}

上述代码反编译后:

1
2
3
monitorenter
...
monitorexit

synchronized对应了两个指令:monitorenter、monitorexit

以JVM中对monitorenter的解释为例:

monitorenter :

Each object is associated with a monitor. A monitor is locked if and only if it has an owner. The thread that executes monitorenter attempts to gain ownership of the monitor associated with objectref, as follows:
• If the entry count of the monitor associated with objectref is zero, the thread enters the monitor and sets its entry count to one. The thread is then the owner of the monitor.
• If the thread already owns the monitor associated with objectref, it reenters the monitor, incrementing its entry count.
• If another thread already owns the monitor associated with objectref, the thread blocks until the monitor’s entry count is zero, then tries again to gain ownership.

翻译一下:每个对象都关联着一个monitor,当monitor被某一个且只能被一个线程占用后,monitor就会处于锁定状态。线程执行到monitorenter后,尝试去获取与monitor关联的对象的所有权,此时,会有两种结果:

  • 如果monitor的Nest值为0,则线程会占有monitor。
  • 如果monitor被占有了,通过owner进行判断是否为当前线程占有的,如果是,那么该线程重入一次,计数器Nest的值加1
  • 如果monitor被其他线程占有了,当前线程阻塞,直到Nest值为0

总结一下:每个对象的对象头中都有一个Mark Word用于存储运行时数据,Mark Word中包含了Lock Word,Lock Word记录了Monitor的指针,Monitor中的owner字段记录了持有该Monitor的线程唯一标识,Nest字段是一个计数器,用来表示该Monitor是被持有了几次,当线程执行到montorenter指令时,会判断计数器,计数器为0时,直接持有该锁,不为0时,进一步判断owner是否为当前线程,为当前线程则将计数器加1,继续持有该锁,不为当前线程则阻塞等待至计数器为0。

synchronized的使用

下面给出synchronized的几种常用应用场景:

  • 普通方法上,锁当前实例对象
  • 静态方法上,锁当前类的class对象
  • 代码块,锁括号里的对象
  • 继承的方法上,锁子类的实例对象,锁两次

这里比较容易令人困惑的是应用在继承方法上:首先,继承的本质是让子类拥有父类对象的引用,super关键字就是告知JVM,子类对象需要通过父类的引用调用父类的方法,因此,调用者是子类对象,锁的也是子类对象。在下面代码示例中,进入子类的paraentMethod()方法时,获取一次子类对象锁,调用super.paraentMethod()时,又一次获取了子类的对象锁,共在子类实例对象上加了两次锁。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
public class SynParent {
public synchronized void paraentMethod() {
System.out.println("method paraent start...");
}
}

public class SynchronizedTest extend SynParent{
private final volatile Object objLock = new Object();

// 用在继承的方法上,锁子类的当前实例对象
@Override
public synchronized void paraentMethod() {
super.paraentMethod();
System.out.println("method paraent end...");
}

// synchronized应用在普通方法上,锁当前实例对象
public synchronized void method1() {
System.out.println("method 1 start...");
// do something

System.out.println("method 1 end...");
}

// synchronized应用在静态方法上,锁当前类的class对象
public static synchronized void method2() {
System.out.println("method 2 start...");
// do something

System.out.println("method 2 end...");
}

// synchronized应用在代码块,锁当前实例对象
public void method3() {
synchronized(this) {
System.out.println("method 3 start...");
// do something
}

System.out.println("method 3 end...");
}

// synchronized应用在代码块,锁自定义实例对象
public void method4() {
synchronized(objLock) {
System.out.println("method 4 start...");
// do something
}

System.out.println("method 4 end...");
}
}

JVM的锁优化

以64位JVM为例,它的对象头中的Mark Word如下,分别对应了对象的四种状态,无锁、偏向锁、轻量级锁、重量级锁,此外,虚拟机还有自旋锁、锁消除、自旋自适应锁等机制,本节将会逐个介绍。

随着锁的竞争,锁可以从偏向锁升级到轻量级锁,再升级的重量级锁,但是锁的升级是单向的,也就是说只能从低到高升级,不会出现锁的降级。

1568963278276

重量级锁就是前面我们详细分析过的synchronized锁,线程需要持有与对象相关联的monitor,montior中包含了线程唯一表示、系统互斥量、计数器等信息。系统互斥量导致该锁是重量级。重量级锁不属于锁优化,所以不再单独列为一节。

轻量级锁

synchronized原理中已提到过,线程栈帧中有一个名为Lock Record(锁记录,又叫Lock Word)的空间,用于存储对象的Mark Word的拷贝。

线程尝试获取轻量级锁时,虚拟机使用CAS将对象的Mark Word更新为指向Lock Record的指针,如果此次更新成功,那么这个线程就拥有了该对象的锁。锁标志位更新为00,之所以称之为轻量级,是去除了同步使用的互斥量

如果CAS操作失败,虚拟机首先检查对象的Mrak Word是否指向当前线程,如果是,那就可以直接进入同步块执行,如果对象的Mark Word没有指向当前线程,说明锁已经被其他线程抢占了,轻量级锁不再有效,膨胀为重量级锁,锁标志位变为10。

偏向锁

如果说轻量级锁是在无竞争的状态下使用CAS操作去除同步使用的互斥量,那偏向锁就是在无竞争的状态下把整个同步都消除掉,连CAS操作也省去。

对象头使用54bit存储偏好的线程ID,再使用2bit存储epoch(偏向锁获取的时间戳),当锁对象第一次被线程获取时,进入偏向模式,同时会进行一次CAS(只进行一次),把获取到该锁的线程ID记录在Mark Word中,该线程以后再进入与锁相关的同步块时,虚拟机不再执行任何同步操作,直至另外一个线程尝试获取该锁。偏向锁可以提高带有同步(如synchronized关键字)但实际运行中无线程竞争的代码的效率,即只有一个线程获取该锁,那么使用偏向锁模式。

偏向锁失败后,并不会立即膨胀为重量级锁,而是先升级为轻量级锁。-XX:+UseBiasedLocking开启偏向锁。

轻量级锁与偏向锁的区别:

  • 偏向锁只执行一次CAS,后续同一个线程获取锁时完全没有同步操作,偏向锁每次都要执行CAS
  • 偏向锁在有其他线程尝试获取锁时就失效,轻量级锁在其他线程获取锁成功后才会失效

自旋锁

如果有两个以上的处理器,处理器A的线程获取了锁,线程B请求获取同一个对象的锁时会阻塞,在大多数情况下,线程A占有锁的时间不会太久,为了这段很短的时间去挂起和恢复线程B并不值得。

因此,JVM让后面请求锁的那个线程B执行一个忙循环(自旋),不放弃处理器的执行时间,看看处理器A的线程是否很快是否锁。这种情况适用于处理器A的线程只需要很短的时间就释放锁,省去了B线程挂起去等待A释放锁和B线程恢复的时间。自旋锁默认开启,默认次数10次,使用-XX:PreBlockSpin设置次数 。

自旋自适应锁

如果对于某个锁,自旋很少成功过,以后获取该锁可能省去自旋过程。如果对于某个锁,经常很短时间就成功,虚拟机认为这次自旋很有可能再次成功,会允许自旋等待更长的时间。有效的解决了自旋等待时间过长时白白耗费CPU资源的问题。

锁消除

消除锁是虚拟机另外一种锁的优化,这种优化更彻底,Java虚拟机在JIT编译时(可以简单理解为当某段代码即将第一次被执行时进行编译,又称即时编译),通过对运行上下文的扫描,去除不可能存在共享资源竞争的锁,通过这种方式消除没有必要的锁,可以节省毫无意义的请求锁时间。

比如编写了一段看起来没有同步的代码,但是经javac编译后,发现包含了三个sb.append()操作,每个sb.appen()方法都包含一个同步块,锁就是sb对象,虚拟机观察sb,发现它的动态作用于被限制在concatString()方法内部,也就是说,其他线程访问不到当前线程的sb对象,因此,这里虽然有锁,但是可以消除。JVM就会消除该锁。

1
2
3
4
5
6
7
8
9
10
11
12
13
// 一段看起来没有同步的代码
public String concatString(String s1, String s2, String s3) {
return s1 + s2 + s3;
}
// javac 转化后,可以看出包含了三个sb.append()操作,这些操作都是同步的,耗费性能
public String concatString(String s1, String s2, String s3) {
StringBuilder sb = new StringBuilder();
sb.append(s1);
sb.append(s2);
sb.append(s3);

return sb.toString();
}

锁粗化

原则上,在编写代码时,锁的粒度越小越好,但是如果一系列连续操作都对同一个对象反复加锁和解锁,甚至加锁出现在循环体中,即使没有线程竞争,频繁的进行互斥同步操作也会导致不必要的性能损耗。

如下面的例子中,第一个while循环对当前实例对象加锁1次,第二个while循环对当前实例对象加锁99次,虚拟机优化后,只加锁了1次。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
public class TooMuchLock {
private int sum;
private int i;
private int j;

public void setSum() {
synchronized(this) {
while(i< 100) {
i++;
}
}
System.out.println("suming...");

while(j < 100) {
synchronized(this) {
j++;
}
}

sum = i + j;
}
}


// 虚拟机锁粗化后
public void setSum() {
synchronized(this) {
while(i < 100) {
i++;
}
System.out.println("suming...");
while(j < 100) {
j++;
}
sum = i + j;
}
}

性能提升原则

开发过程中,尽量遵循以下原则:

  • 尽量将不影响共享状态且执行时间较长的操作从同步代码块中分离出去,比如尽量不要对I/O操作加锁。
  • 不要频繁的对同一个对象加锁,即使虚拟机有锁粗化机制
  • 不要盲目的为了提高性能而细化锁的粒度,细化锁的粒度时,要时刻警惕线程安全性。

可见性

通常处理器和内存之间都有几级缓存来提高处理速度,处理器先将内存中的数据读取到内部缓存后再进行操作,但是对于缓存写会内存的时机则无法得知,因此在一个处理器里修改的变量值,不一定能及时写会缓存,这种变量修改对其他处理器变得“不可见”了。

因此,可见性指的是内存可见性,当一个线程修改了对象状态后,其他线程能够看到发生的状态变化。与原子性对比如下:

  • 原子性:一个线程使用对象期间,对象不被其他线程修改。

  • 可见性:一个线程A使用对象期间,对象可以被其他线程修改,但是线程A能够看到发生的变化。

通过加锁实现的原子性可以保证可见性:线程A执行某个同步代码块时,线程B随后进入同一个锁保护的同步代码块,在这种情况下,可以保证线程B获取锁后可以看到线程A之前在同一个同步代码块中的所有操作。因此,加锁的含义不仅仅局限于互斥行为,还包括内存可见性

但在不要求互斥、只要求内存可见性的情况下,再使用锁就显得有些重了,此时可以使用volatile变量,它可以保证内存可见性,变量的修改通知到其他线程。

volatile原理

Java代码

1
private volatile TestInstance instance = new TestInstance();

上述代码的汇编代码:

1
2
0x01a3de1d: movb $0x0,0x1104800(%esi);
0x01a3de24: lock addl $0x0,(%esp);

有Violatile修饰的变量在汇编阶段,会多出一条lock前缀指令,它在多核处理器下引发两件事情:

  • 将当前处理器缓存行的数据写回内存
  • 写回内存的操作会使在其他CPU里缓存了该内存地址的数据无效(缓存一致性协议)

回写内存

处理器为了提高处理速度,不直接和内存进行通讯,而是先将系统内存的数据读到内部缓存(L1,L2或其他)后再进行操作,但操作完之后不知道何时回写内存,如果对声明了Volatile变量进行写操作,JVM就会向处理器发送一条lock前缀的指令,将这个变量所在缓存行的数据写回到系统内存

缓存一致性协议

使用Volatile修饰的变量,在写操作的时候,会强制将这个变量所在缓存行的数据写回到内存中,但即使写回到内存,其他处理器也有可能使用内部的缓存数据,从而导致变量不一致;所以,在多处理器下,为了保证各个处理器的缓存是一致的,就会实现缓存一致性协议:每个处理器通过嗅探在总线上传播的数据来检查自己缓存的值是不是过期,如果处理器发现自己缓存行对应的内存地址被修改,就会将该缓存行设置成无效状态,下次要使用就会重新从内存中读取。

lock信号

volatile的核心是lock前缀指令,它负责通知cpu将当前操作立即回写内存,正是因为回写内存的存在,指令重排无法跨过lock信号对应的指令。因此,lock前缀实际上是一种内存屏障,cpu不会跨过该屏障进行重排序,volatie不仅可以保证可见性,也保证有序性。

volatile使用场景

当且仅当满足以下所有条件时,才应该使用volatile变量:

  • 对该变量的写入操作不依赖变量的当前值,或者能确保只有一个线程更新变量的值

  • 该变量不与其他变量一起纳入不变性条件中(因为volatile变量不能确保原子性)

举例一些应用场景:标识一些事件的发生,如初始化、销毁、判断是否处于某个状态,状态的变化只有一个线程能够触发。

小结

本篇主要讨论了以下内容:

  • 通过CAS、对象锁保证原子性;
  • 通过volatile、对象锁保证可见性、有序性。
  • CAS存在的三个问题
  • 缓存一致性协议的定义
  • JVM的锁优化方法。

下一篇主要介绍对象的安全发布。