阻塞同步:互斥同步最主要的问题是进行线程阻塞和唤醒所带来的性能问题(主要是上下文切换),这种同步也称为阻塞同步(Blocking Synchronization)。
互斥同步是一种悲观的并发策略,总是认为只要不去做正确的同步措施,那就肯定会出现问题,无论共享数据是否真的会出现竞争,它都要进行加锁、用户态核心转换、维护锁计数器和检查是否有被阻塞的线程需要唤醒等操作。
实际上JVM会优化掉很大一部分不必要的加锁操作。比如无竞争的同步,当一个锁对象只能由当前线程访问,那么JVM就可以通过优化来去掉这个锁的获取操作。
非阻塞同步:基于冲突检测的乐观并发策略,也就是说,先进行操作,如果没有其他线程争用共享数据,那操作就成功;如果共享数据有争用,产生了冲突,那就在采取其他的补偿措施(常见的如:不断重试,直到成功),这种乐观的并发策略的许多实现都不需要把线程挂起,因此称为非阻塞同步(Non-Blocking Synchronization).
使用乐观并发策略需要“硬件指令集的发展”支持,因为操作和冲突检测这两个步骤具备原子性,需要靠硬件来完成。
硬件保证一个语义上看起来需要多次操作的行为通过一条处理器指令就能完成,这类指令常用的有:
CAS指令需要3个操作数,分别是-内存位置 V(在Java中可以简单理解为变量的内存地址)、 旧的预期值 A(进行运算前从内存中读取的值)、拟写入的值 B(运算得到的值)
当且仅当 V==A 时, 才执行V = B (将B赋给V),否则将不做任何操作。
模拟CAS操作:
public class CompareAndSwap{
private int value;
//获取内存值
public synchronized int get() {
return value;
}
/**
* 比较当前内存值和旧的预期值,只有两个值相等的情况,进行更新
* @param expectedValue 旧的预期值 - 在进行运算前从内存中读取的值
* @param newValue 拟写入的新值 - 运算得到的值,即拟写入内存的值
* @return
*/
public synchronized int compareAndSwap(int expectedValue, int newValue){
int oldValue = value;
//比较当前内存值和旧的预期值 如果相等,将更新值赋给内存值
if (oldValue == expectedValue) {
this.value = newValue;
}
return oldValue;
}
//设置
public synchronized boolean compareAndSet(int expectedValue, int newValue){
return expectedValue == compareAndSwap(expectedValue, newValue);
}
}
常见的使用情况是:线程首先从内存位置V中读取到预期值A,在执行计算前,比较当前内存值和旧的预期值A是否相等,如果相等,计算得到的值赋给内存值。 不相等则说明,期间有其他线程修改了内存位置V的值。
当多个线程使用CAS同时更新一个变量值时,只有其中一个线程能够更新成功,其他的线程都将失败。但是,失败的线程不会被挂起(但如果获取锁失败,线程将被挂起),而是返回失败状态,调用者线程可以选择是否需要再一次尝试(如果是在一些竞争激烈的情况下,更好的方式是在重试之前等待一段时间或者回退,从而避免活锁问题–不断重试,不断失败),或者执行一些恢复操作,也可以什么都不做。
JDK1.5后,Java程序中才可以使用CAS操作,该操作由sun.misc.Unsafe类里面的compareAndSwapInt()和compareAndSwapLong()等几个方法包装提供。但是Unsafe类不是提供给用户程序调用的 (Unsafe.getUnsafe()代码限制了只有启动类加载器(Bootstrap ClassLoader)加载的Class才能访问) 因此,如果不使用反射,只能通过其他的java API来间接使用。 比如java.util.concurrent.atomic中的原子类。其中整数原子类有compareAndSet() 和 getAndIncrement()方法都是用了Unsafe类的CAS操作。
现在来看一段代码,测试原子类:
public class TestAtomicDemo {
public static void main(String[] args) {
AtomicDemo atomicDemo = new AtomicDemo();
for (int i = 0; i < 10; i++) {
new Thread(atomicDemo).start();
}
}
}
class AtomicDemo implements Runnable {
//private volatile int serialNumber = 0;
private static AtomicInteger serialNumber = new AtomicInteger(0);
@Override
public void run() {
try {
Thread.sleep(200);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName()+ "--" +getSerialNumber());
}
public int getSerialNumber() {
//i++为复合操作,使用原子类保证操作原子性
return serialNumber.getAndIncrement();
//return serialNumber++;
}
}
在这段代码中,如果不使用原子类 AtomicInteger 代替 int serialNumber(volatile 关键字修饰也不可以,因为自增运算是复合操作),那么可能不会得到正确结果输出0-9,如以下输出情况:
Thread-0--0
Thread-3--6
Thread-5--4
Thread-7--5
Thread-2--3
Thread-4--2
Thread-9--1
Thread-1--0
Thread-8--0
Thread-6--7
能够得到正确输出是因为getAndIncrement()这个方法保证了原子性。
1.它使调用者处理竞争问题(重试、回退、停止),而在锁中能自动处理竞争问题(获得锁之前一直阻塞)
2.循环时间长导致开销增大,如果程序中的CAS操作不断重试(自旋),会使得CPU消耗过多的执行资源。
3.只能保证一个变量的原子操作,对多个共享变量操作时,CAS无法保证操作的原子性,但是可以把多个变量合并成一个共享变量来操作。如:有两个共享变量 i = 2, j = a,可以合并为 ij = 2a,然后使用CAS来操作。当然可已使用基于CAS的原子类 AtomicReference 来保证对象间的原子性,将多个变量放在一个对象中进行操作。
4.如果一个变量V初次读取的时候是A值,并且准备赋值时检查到它仍然是A值,但是这段时间中,它的值可能被改为B后又改了回来,这时CAS操作就会误认为它从来没有改变过。这就是CAS操作存在的一个漏洞“ABA”问题。而解决ABA问题使用互斥同步可能会更有效。
给出一种ABA问题的解决思路:
在变量前面追加上版本号,每次变量更新,将版本号加1,A->B->A 将变成 1A->2B->3A
JDK1.5开始,atomic 包中提供了一个类 AtomicStampedReference 来解决这个问题.
public boolean compareAndSet{
V expectedReference; //预期引用
V newReference; //更新后的引用
int expectedStamp; //预期标志
int newStamp; //更新后的标志
}
这个类的 compareAndSet 方法作用是首先检查当前引用是否等于预期引用,标志是否等于预期标志,如果全部相等,则以原子方式将该引用和该标志值设置为给定的更新值。