jvm(4)--线程安全

前言

在操作系统中,一段时间内只允许一个进程访问的资源成为临界资源。包括软件临界资源和硬件临界资源,例如打印机、栈、变量、表格等。进程中访问临界资源的代码片段成为临界区,按照临界资源的特征,临界区的代码要实现对临界资源的互斥访问。

硬件同步机制

现在,大多数情况通过硬件来解决临界区的同步问题。硬件同步方法如下:

  • 关中断

锁测试之前关闭中断,可保证同一个处理器中,计算机系统不响应其他的中断请求,无法进行线程的调度。这种方法简单粗暴,但影响处理器执行效率且只适应与单处理机系统。

  • Test-and-set指令(TS)

该指令具有原子性,不可分割。TS指令管理临界区时,为每个临界资源上一把锁lock,初始值为false表示该资源空闲,当有线程访问空闲资源(lock为false)时,会将lock设为true以阻止其他进程访问。伪代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
boolean TS(boolean *lock){
boolean old;
old = *lock;
*lock = true;
return old;
}
do{
……
// 获取锁
while(TS(&lock));
//临界区代码
……
……
// 释放锁
lock = false;
reminder section;
}while(true);
  • swap指令实现互斥

通过swap指令实现互斥,对每个临界资源设置一个全局变量锁lock,false为空闲可访问。每个进程配置一个局部变量key,通过交换的方式实现互斥。伪代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
void swap(boolean *a, boolean *b){
boolean temp;
temp = *a;
*a = *b;
*b = temp;
}

do{
key = true;
do{
swap(&lock, &key);
}while(key != false);
// 临界区代码
……
lock = false;
}
  • 信号量机制

迪杰斯特拉(跨学科大牛),提出一种新方法来实现线程同步:信号量机制。用一个整形变量S表示资源数目,这个整形变量除初始化外仅能通过两个原子操作wait(S)、signal(S)来访问,即PV操作。

整型信号量

很多临界资源可能不止一份,可以支持最多S个进程同时访问。同时访问的进程数量超过S后,后面的进程将被阻塞。伪代码如下:

1
2
3
4
5
6
7
wait(S){
while(S <= 0);
S--;
}
signal(S){
S++;
}

记录型信号量

为减少资源浪费,避免在S<=0时出现忙等的状态,设计出新的信号量机制:记录型信号量。原理是将要访问的临界资源不足时,调用block原语阻塞当先操作,并将PCB(进程控制块)插入到阻塞队列,释放处理机。当资源释放后,调用wakeup原语唤醒阻塞队列的第一个等待进程。当S的value为1时,记录型信号量就变成互斥信号量,同时只允许一个进程访问。伪代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
typedef struct{
int value;
struct process_control_block *list;
}semaphore;
wait(semaphore *S){
S->value--;
if(S->value < 0) block(S->list);
}
signal(semaphore *S){
S->value++;
if(S->value <= 0) wakeup(S->list);
}

AND型信号量

现实情况中,进程在访问临界资源时,可能同时需要好几种临界资源。此时可能出现死锁的情况,为避免出现死锁,需要一种新的信号量机制:AND型信号量。当这个进程可以获取到所需的所有临界资源时,则全部分配给该进程,若有一个临界资源无法得到,则释放所有的临界资源,即持有0 或者 所需的所有临界资源。

信号量集

记录型信号量只对某一中信号量进行加1或者减1操作,若想对n种信号量进行加m或者减m操作,则需要对n种信号量分别执行m次锁操作,这可能会增大死锁的概率。为解决此问题,对AND型信号量、记录型信号量进行扩充得到信号量集。

如下所示:

Swait(S1,t1,d1,……Sn,tn,dn);

Ssignal(S1,d1,……Sn,dn);

Sn表示临界资源n的信号量。

tn表示当临界资源小于tn时,则不予进程分配信号量。

dn表示进程对该资源的需求量为dn。

线程安全

关于线程安,jvm虚拟机原理和java并发编程实战中有个比较合理的定义:

当多个线程访问同一个对象时,如果不用考虑这些线程在运行时环境下的调度和交替执行,也不需要额外的同步,或者在调用方进行任何其他的协调操作,调用这个对象的行为都可以获得争取的结果,那这个对象就是线程安全的。

java中共享数据可分为以下5类:

  • 不可变:final修饰的变量,String对象、Long类型对象、Double类型对象等。
  • 绝对线程安全:上面定义中的变量,在多线程环境下不需任何同步操作就可得到正确的结果 。
  • 相对线程安全:通常意义上的线程安全,通过一些额外的同步机制来保证调用的正确性,例如Vector、Stack、HashTable、Enumeration。
  • 线程兼容:本身并不是线程安全的,但在调用端通过正确的同步手段即可保证对象在并发环境中的正确使用。
  • 线程对立:无论采用何种同步机制,都无法在多线程环境中并发执行。例如Thread.suspend()和Thread.resume()方法。

在java中主要通过两大类方法来实现同步机制:互斥同步又叫阻塞同步、非阻塞同步。互斥同步通过synchrinized来实现,非阻塞同步通过CAS来实现。

synchronized原理

synchronized可用在对象实例、方法、变量、代码块上,被synchronized修饰的地方前后会出现两个字节码指令:monitorenter、monitorexit两个字节码指令。这个两个字节码需要一个reference类型参数来指明要锁的对象。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class MutualExclusion {

private String a;

public String getA() {
return a;
}

public void setA(String b) {
synchronized (b) {
this.a = b;
}
}
public static void main(String[] args) {

}
}

setA()方法的class文件字节码为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public void setA(java.lang.String);
descriptor: (Ljava/lang/String;)V
flags: ACC_PUBLIC
Code:
stack=2, locals=4, args_size=2
0: aload_1
1: dup
2: astore_2
3: monitorenter
4: aload_0
5: aload_1
6: putfield #2 // Field a:Ljava/lang/String;
9: aload_2
10: monitorexit
11: goto 19
14: astore_3
15: aload_2
16: monitorexit
17: aload_3
18: athrow
19: return

java的线程是映射到内核线程之上的,故线程的阻塞或者唤醒都要从用户态转换到内核态,这个转换的开销可能比用户代码的开销还要大,故一般说synchronized是重锁。

CAS原理(乐观锁)

阻塞同步属于悲观锁,无论是否有多个线程并发访问临界资源,都进行加锁。实际场景中可能大多数临界资源的访问不存在线程之间的竞争,此时加锁,用户态核心态转换的开销是浪费掉的,并没有什么实际意义。

非阻塞同步属于乐观锁,即先使用共享变量进行操作,如果没有其他线程竞争则操作成功,否则采取补偿机制,补偿机制一般是不断重试,直至成功为止。

计算机硬件层面退出CAS指令来帮助实现乐观锁。CAS指令包含三个操作数:V、A、B。

  • V 表示变量的内存地址
  • A 旧的预期值
  • B 新值

当且仅当V处的值与A相同时,处理器用B更新V处的值,否则不更新。这个CAS操作包含在java的UNSAFE类中,但方法都是JNI类型的方法,并不直接供用户使用。Atomic类以及ConcurrentHashMap等jdk源码中都有用到CAS方法。

CAS简易理解例子:

内存V处共享变量a的旧值A,在多线程环境中,我们对a执行加1操作,即新值B=A+1。在执行更新操作时,当V处的值仍为A时(没有被其他线程修改),则处理器用B更新V处的值。

AtomicInteger中的getAndSet()方法,首先根据unsafe.objectFieldOffset方法获取value的内存地址。在getAndSetInt方法中,

  • var2表示内存地址,

  • var5是var2处的旧期望值,

  • var4是新值,当var2处为var5时,用var4更新var2处的内容,若更新失败则不断重试。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
private static final long valueOffset;
static {
try {
valueOffset = unsafe.objectFieldOffset
(AtomicInteger.class.getDeclaredField("value"));
} catch (Exception ex) { throw new Error(ex); }
}

public final int getAndSet(int newValue) {
return unsafe.getAndSetInt(this, valueOffset, newValue);
}

public final int getAndSetInt(Object var1, long var2, int var4) {
int var5;
do {
var5 = this.getIntVolatile(var1, var2);
} while(!this.compareAndSwapInt(var1, var2, var5, var4));

return var5;
}

ABA问题:

ABA问题,即如果在初次获取V处的值时为A,下次读取之前,变量由A变成B,再由B变成A,此时再次读取看起来好像V处的值没有发生过变化,但实际已经改变过。

解决方案:为每个值添加版本信息,可解决ABA问题,例如A(v1)-> B(v1)-> A(v2)。

但实际场景中并不需要添加版本信息,因为ABA并不会影响程序并发的正确性。

锁优化

自jdk1.6以后,HotSpot虚拟机团队花费大量精力去实现锁优化。主要包括:自旋锁、自适应锁、锁消除、锁粗化、轻量级锁和偏向锁等。

自旋锁、自适应锁

在有些场景,锁可能在尝试获取时持有锁的线程没有释放,但短暂时刻后马上释放。当某个线程去获取锁时,如果无法获取到就执行一个忙循环等待锁的释放,不放弃CPU处理时间,这样做可以减少线程切换的开销。

如下图1所示:线程A持有锁,线程B尝试失败后没有自旋则需要线程切换。线程C尝试失败后自旋等待,带A释放锁后便可立刻获取,与B相比省去线程切换的开销。

图 1 自旋锁示意

在锁被占用的时间较短时,自旋锁可以较大的提高性能。但若是锁被占用时间很长时,则自旋等待会占用CPU资源,也是一种浪费。针对这种场景jdk1.6中引入了自适应锁,就是在自旋等待时加上自旋时间,如果自旋或得锁的频率很高,那么允许现场自旋更长的时间。若自旋等待很少成功获取锁,那么就要取消自旋等待操作。

锁消除、锁粗化

锁消除,顾名思义就是要消除不必要的锁。如果堆上的数据都不会逃逸出去(逃逸分析不懂)而被其他线程访问到,那可以将这个对数据当栈数据处理。

锁粗化,理论上讲,同步的代码块应该尽可能的小,来减小线程同步的操作数量。但少数情况中,对一个对象反复的上锁和解锁,即使没有其他线程竞争也反复进行互斥同步操作会带来不必要的性能开销。例如StringBuffer的append方法。为此针对这种情况需要请锁的范围扩大,粗化到操作序列的外围减少解锁枷锁的操作。

轻量级锁、偏向锁

轻量级锁是采用CAS的原理,在同步对象的头部存储一个Mark Word信息,包含对象的HashCode、GC年龄、锁标志位。

在线程的栈帧中分配出一块名为锁记录(Lock Record)的空间,将同步对象的Mark Word信息复制到锁记录中。然后利用CAS将对象的Mark Word更新为指向锁记录的指针。若更新成功,则该线程获取该对象的锁,并且将对象的Mark Word锁标志位改为00(轻量级锁定)。

若失败,则检查对象的Mark Word是否指向当前线程的栈帧,若指向,则该线程已拥有该对象的锁,否则证明多线程同时竞争,对象头中的所标志位变为10(重量级锁定)。

偏向锁与轻量级锁类似,都需要用的对象头Mark Word信息。如果对象头中的锁标志位是可偏向01,那么第一个获取该对象锁的线程将会一直持有锁,不需要进行同步操作。直到有另外一个线程来竞争这个锁时,偏向模式结束。若对象被锁定,对象头标志位变为轻量级锁定,若未被锁定则标志位变为未锁定。