并发的学习与使用系列 第二篇

在多线程的操作中,最常用到synchronized关键字和volatile来控制线程的同步问题,要想正确的使用必须对其原理有一定的了解。接下来将通过对其原理的分析以及一些实际中的使用来具体分析它们起到的作用,以及一种可能未见过的单例模式来了解AtomicReference相关原子类的使用。

synchronized 关键字


synchronized关键字是用来控制线程同步的,就是在多线程的环境下,控制synchronized代码段不被多个线程同时执行。是一种阻塞性的锁,
synchronized既可以加在对象上,也可以加在方法上。

实现原理

每个对象有一个监视器锁——monitor对象。当monitor被占用时就会处于锁定状态。

对于对象的同步,synchronized会在进入同步块(对象)的前后分别形成monitorenter和monitorexit字节码指令。在执行monitorenter指令时会尝试获取monitor对象的所有权,如果此时对象没有被锁,或者此对象已经被当前线程锁住,那么锁的计数器加一,每当monitorexit被锁的对象的计数器减1。直到为0就释放该对象的锁。由此synchronized是可重入的,不会出现自己把自己锁死,所以无需(也不能)人工干预锁的获取和释放。

而方法的同步并没有通过指令monitorenter和monitorexit来完成,相对于普通方法,其常量池中多了ACC-SYNCHRONIZED标示符。JVM就是根据该标示符来实现方法的同步的:当方法调用时,调用指令将会检查方法的 ACC_SYNCHRONIZED 访问标志是否被设置,如果设置了,执行线程将先获取monitor,获取成功之后才能执行方法体,方法执行完后再释放monitor。在方法执行期间,其他任何线程都无法再获得同一个monitor对象。 其实本质上没有区别,只是方法的同步是一种隐式的方式来实现,无需通过字节码来完成。

使用细节

synchronized(this)及非static的synchronized方法,只能防止多个线程同时执行同一个对象的同步代码段。当synchronized锁住一个对象后,别的线程如果也想拿到这个对象的锁,就必须等待这个线程执行完成释放锁,才能再次给对象加锁,这样才达到线程同步的目的。即使两个不同的代码段,都要锁同一个对象,那么这两个代码段也不能在多线程环境下同时运行。

所以我们在用synchronized关键字的时候,能缩小代码段的范围就尽量缩小,能在代码段上加同步就不要再整个方法上加同步。这叫减小锁的粒度,使代码更大程度的并发。原因是基于以上的思想,锁的代码段太长了,别的线程是不是要等很久。

如果用synchronized加在静态方法上,就相当于用××××.class锁住整个方法内的代码块,此时是锁住该类的Class对象,相当于一个全局锁。

一个线程执行互斥代码过程如下:

1. 获得同步锁;
2. 清空工作内存;
3. 从主内存拷贝对象副本到工作内存;
4. 执行代码(计算或者输出等);
5. 刷新主内存数据;
6. 释放同步锁。

所以,使用synchronized修饰的方法或者代码块可以看成是一个原子操作,所以synchronized的既保证了多线程的并发有序性,又保证了多线程的内存可见性。

volatile(非阻塞性的)


volatile的特性当我们声明共享变量为volatile后,对这个变量的读/写将会很特别。理解volatile特性的一个好方法是:把对volatile变量的单个读/写,看成是使用同一个监视器锁对这些单个读/写操作做了同步。

它的工作原理是,它对写和读都是直接操作工作主存的。下面我们通过具体的示例来说明,请看下面的示例代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class VolatileFeaturesExample {
volatile long vl = 0L; //使用volatile声明64位的long型变量
public void set(long l) {
vl = l; //单个volatile变量的写
}
public void getAndIncrement () {
vl++; //复合(多个)volatile变量的读/写
}
public long get() {
return vl; //单个volatile变量的读
}
}

假设有多个线程分别调用上面程序的三个方法,这个程序在语意上和下面程序等价:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class VolatileFeaturesExample {
long vl = 0L; // 64位的long型普通变量
//对单个的普通变量的写用同一个监视器同步
public synchronized void set(long l) {
vl = l;
}
public void getAndIncrement () {
//普通方法调用
long temp = get(); //调用已同步的读方法
temp += 1L; //普通写操作
set(temp); //调用已同步的写方法
}
public synchronized long get() {
//对单个的普通变量的读用同一个监视器同步
return vl;
}
}

临界区代码的执行具有原子性。这意味着即使是64位的long型和double型变量,只要它是volatile变量,对该变量的读写就将具有原子性。
如果是多个volatile操作或类似于volatile++这种复合操作,这些操作整体上不具有原子性。简而言之,volatile变量自身具有下列特性:

  • 可见性: 对一个volatile变量的读,总是能看到(任意线程)对这个volatile变量最后的写入。
  • 对 volatile 变量的写操作,不允许和它之前的读写操作打乱顺序;对 volatile 变量的读操作,不允许和它之后的读写乱序。

volatile不能保证原子性,原子性不是volatile来保证的,如果操作本来就具有原子性,volatile就会保证这个原子性不会被打破,理解为加上同步。比如上面例子中的get和set函数。

以及下面这段线程不安全的singleton(单例模式)实现,尽管使用了volatile:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class UnSafeSingleton {
private static volatile UnSafeSingleton
sInstance = null;
private UnSafeSingleton() {}
public static UnSafeSingleton getInstance(){
if (sInstance == null) {
sInstance = new UnSafeSingleton();
}
return sInstance;
}
}

关于原子性的理解:只对单个操作就有原子性,比如i++这种就不是单个操作。

单例模式的应用


“饿汉式”

1
2
3
4
5
6
7
8
9
10
public class Singleton {
private static Singleton instance = new Singleton();
private Singleton (){}
public static Singleton getInstance() {
return instance;
}
}

这种方式比较常用,但容易产生垃圾对象。

优点:没有加锁,执行效率会提高。

缺点:类加载时就初始化,对一些需要传入非静态参数类的不适用。

它基于 Classloader 机制避免了多线程的同步问题,不过,instance 在类装载时就实例化,虽然导致类装载的原因有很多种,在单例模式中大多数都是调用 getInstance 方法, 但是也不能确定有其他的方式(或者其他的静态方法,比如反射)导致类装载,这时候初始化 instance显然没有达到 lazy loading 的效果。

一个双重锁定式的单例模式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public class Singleton {
private volatile static Singleton sSingleton;
private Singleton (){}
public static Singleton getSingleton() {
if (sSingleton == null) {
synchronized (Singleton.class) {
if (sSingleton == null) {
sSingleton = new Singleton();
}
}
}
return sSingleton;
}
}

讨论下volatile关键字的必要性,如果没有volatile关键字,问题可能会出在singleton = new Singleton();这句,用伪代码表示

1
2
3
inst = allocat(); // 分配内存
sSingleton = inst; // 赋值
constructor(inst); // 真正执行构造函数

可能会由于虚拟机的优化等导致赋值操作先执行,而构造函数还没完成,导致其他线程访问得到singleton变量不为null,但初始化还未完成,导致程序崩溃。

单例模式的一种新的方式

最近在读RxJava的源码时,见到了一种新的单例模式,可能是自己见识太少,之前对这种方式真的没见过,也可以说闻所未闻。由此引发了一些对Atomic相关原子类的思考的研究,先看代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public class Singleton {
private static final AtomicReference<Singleton> INSTANCE = new AtomicReference<Singleton>();
private Singleton (){}
public static Singleton getInstance() {
for (;;) {
Singleton current = INSTANCE.get();
if (current != null) {
return current;
}
current = new Singleton();
if (INSTANCE.compareAndSet(null, current)) {
return current;
}
}
}
}

先看下AtomicReference的源码:

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
public class AtomicReference<V> implements java.io.Serializable {
private static final long serialVersionUID = -1848883965231344442L;
private static final Unsafe unsafe = Unsafe.getUnsafe();
private volatile V value;
public AtomicReference(V initialValue) {
value = initialValue;
}
public final V get() {
return value;
}
public final void set(V newValue) {
value = newValue;
}
public final boolean compareAndSet(V expect, V update) {
return unsafe.compareAndSwapObject(this, valueOffset, expect, update);
}
public final V getAndSet(V newValue) {
while (true) {
V x = get();
if (compareAndSet(x, newValue))
return x;
}
}
...
}

AtomicReference是作用是对”对象”进行原子操作。通过源码可以看出,它是通过”volatile”和”Unsafe提供的CAS(比较与交换,Compare and swap,是一种有名的无锁算法函数)实现原子操作。现在再来分析上面的那个单例的写法:

  • current是volatile类型。这保证了:当某线程修改value的值时,其他线程看到的value值都是最新的value值,即修改之后的volatile的值。
  • 通过CAS设置value。这保证了:当某线程池通过CAS函数(如compareAndSet函数)设置value时,它的操作是原子的,即线程在操作value时不会被中断。CAS是一种无阻塞的锁,采用不断比较设值的方式来避免并发问题,不会有锁的等待和上下文切换问题,性能消耗较小。

如果当前值 == 预期值,则以原子方式将该值设置为给定的更新值。这种方式既能够保证延迟加载又能保证原子性及实例的唯一性,代码也相对比较简洁。

通过并发的学习与使用系列,线程的阻塞和上下文的切换会带来一定的性能开销,尤其在高并发的环境下。而原子变量可以避免优先级倒置和死锁等危险,竞争比较便宜,协调发生在更细的粒度级别,允许更高程度的并行机制等等。这也是RxJava中选择这种单例方式的原因吧。

但是,轻易不要用volatile或者Atomic相关类来替代synchronized来避免并发问题,因为很多原子操作自己可能并没那么了解,并且不同jdk或虚拟机上这些类可能有不同的表现。除非你很确定替换会有很好的效果,《java编程思想》的作者是这样建议的。