Java并发编程 I - 并发问题的源头
Java并发编程 II - 没有共享就没有伤害(ThreadLoacl)
Java并发编程 III - 让共享数据只读(final关键字)
Java并发编程 IV - volatile关键字与Atomic类
Java并发编程 V - 并发的万能钥匙synchronized
Java并发编程 VI - 线程生命周期与线程间的协作
Java并发编程 VII - Lock
文章目录
volatile关键字的作用
保证可见性:所有线程都能看到共享内存的最新状态。
当程序执行到volatile变量的读或写时,在其前面的操作肯定全部已经执行完毕且结果对后面的操作可见。
保证有序性:禁止指令重排。在CPU、编译器进行指令优化时,不能把volatile变量后面的语句放到其前面执行,也不能把volatile变量前面的语句放到其后面执行。
注意:volatile关键字无法保证原子性。
volatile关键字的应用
可见性问题的例子
public class V {
private static boolean bool = false;
public static void b_test(){
new Thread(new Runnable() {//线程1
@Override
public void run() {
System.out.println("11111");
while (!bool){ }
System.out.println("22222");
}
}).start();
try {
Thread.sleep(10);
} catch (InterruptedException e) {
e.printStackTrace();
}
new Thread(new Runnable() { //线程2
@Override
public void run() {
System.out.println("33333");
bool = true;
System.out.println("44444");
}
}).start();
}
public static void main(String[] args) {
b_test();
}
/* 输出 ->
11111
33333
44444
线程2明明已经将bool设置为true了,为什么线程1没有结束循环呢?
因为线程有自己的缓存区域,会先把共享内存中的变量拷贝到自己的缓存区域中。所以这就导致了线程2刷新了共享内存中的bool值,但线程1依旧使用自己缓存的bool值,最终导致线程1一直无法退出。
*/
}
想要解决上边例子中出现的可见性问题,只需要给bool变量加上volatile关键字即可。
有序性问题的例子
public class V {
private static V instance;
private V(){}
public static V getInstance() {
if (instance==null){ //首次检查
synchronized (V.class){ //通过synchronized保证了代码块跟上下文的可见性、原子性、有序性
if (instance == null){ //二次检查
instance = new V();
}
}
}
return instance;
}
}
上面是一个经典的单例实现方式,双重检查锁(Double Check Lock)单例。但是这个单例并不完美,在多线程模式下getInstance仍然可能出现问题,会可能由于指令重排出现有序性问题。
不是synchronized就能保证了有序性了吗,为什么还会出现有序性问题?synchronized只能保证受保护的代码块跟与上下文的有序性,而不能保证代码块内的有序性。
//原因出在:
instance = new V();
//这一行代码并不是原子操作,而是由三个操作完成的
//1、为instance开辟一块内存空间
//2、初始化对象
//3、instance指向刚分配的内存地址
//由于编译器优化,出现指令重排,变成了
//1、为instance开辟一块内存空间
//3、instance指向刚分配的内存地址
//2、初始化对象
//假设线程A执行到了“instance指向刚分配的内存地址”这一步,那么instance就不为空了。
//这个时候线程B正好执行getInstance()的首次检查,发现instance不为空直接返回了。
//但是这个instance对象可能并未初始化完成。
想要解决上边例子中出现的有序性问题,只需要给instance变量加上volatile关键字即可。
volatile是如何保证可见性、有序性的
内存屏障
内存屏障(Memory Barrier)是一类同步屏障指令,是CPU或编译器在对内存随机访问的操作中的一个同步点,使得此点之前的所有读写操作都执行后才可以开始执行此点之后的操作。
硬件层的内存屏障分为两种:Load Barrier 读屏障;Store Barrier 写屏障。
对Load Barrier来说,在读指令前插入读屏障,可以让高速缓存中的数据失效,重新从主内存加载数据。
对Store Barrier来说,在写指令之后插入写屏障,能让写入缓存的最新数据写回到主内存。
内存屏障的作用是:
1、阻止内存屏障两侧的指令重排;
2、强制把写缓存区/高速缓存区中的脏数据等写回主内存,让缓存中相应的数据失效。
lock指令前缀
lock指令前缀实现了内存屏障类似的功能。
lock指令会使紧跟在其后面的指令变成原子操作。暂时的锁一下总线,指令执行完了,总线就解锁了
lock指令是一个汇编层面的指令,在一些特殊的场景下,作为前缀加在以下汇编指令之前,保证操作的原子性,这种指令被称为 “lock前缀指令”。
ADD, ADC, BTC, BTR, BTS, CMPXCHG,DEC, INC, NEG, NOT, OR, SBB, SUB, XOR, XADD, XCHG.
lock指令的作用:
- 锁总线,其它CPU对内存的读写请求都会被阻塞,直到锁释放,不过实际后来的处理器都采用锁缓存替代锁总线,因为锁总线的开销比较大,锁总线期间其他CPU没法访问内存;
- lock后的写操作会回写已修改的数据,同时让其它CPU相关缓存行失效,从而重新从主存中加载最新的数据;
- 不是内存屏障却能完成类似内存屏障的功能,阻止屏障两遍的指令重排序。
例如:
当两条线程Thread-A与Threab-B同时操作主存中的一个volatile变量i时,Thread-A写了变量i,那么:
- Thread-A发出LOCK#指令
- 发出的LOCK#指令锁总线(或锁缓存行),同时让Thread-B高速缓存中的缓存行内容失效
- Thread-A向主存回写最新修改的i
Thread-B读取变量i,那么:
- Thread-B发现对应地址的缓存行被锁了,等待锁的释放,缓存一致性协议会保证它读取到最新的值
由此可以看出,volatile关键字的读和普通变量的读取相比基本没差别,差别主要还是在变量的写操作上。
volatile关键字是通过lock指令在汇编层面实现的。
//V.java
public class V {
private static volatile int a_value;
public static void a_test(){
a_value = 1;
}
public static void main(String[] args) {
a_test();
}
}
//查看V.java的a_test()方法的汇编代码
Code:
[Disassembling for mach='i386:x86-64']
[Entry Point]
[Verified Entry Point]
[Constants]
# {method} {0x0000000112830a48} 'a_test' '()V' in 'com/llk/kt/V'
# [sp+0x40] (sp of caller)
0x0000000117fb6d20: mov %eax,-0x14000(%rsp)
0x0000000117fb6d27: push %rbp
0x0000000117fb6d28: sub $0x30,%rsp
0x0000000117fb6d2c: movabs $0x112830bf0,%rsi ; {metadata(method data for {method} {0x0000000112830a48} 'a_test' '()V' in 'com/llk/kt/V')}
0x0000000117fb6d36: mov 0xdc(%rsi),%edi
0x0000000117fb6d3c: add $0x8,%edi
0x0000000117fb6d3f: mov %edi,0xdc(%rsi)
0x0000000117fb6d45: movabs $0x112830a48,%rsi ; {metadata({method} {0x0000000112830a48} 'a_test' '()V' in 'com/llk/kt/V')}
0x0000000117fb6d4f: and $0x0,%edi
0x0000000117fb6d52: cmp $0x0,%edi
0x0000000117fb6d55: je 0x0000000117fb6d7e ;*iconst_1
; - com.llk.kt.V::a_test@0 (line 7)
0x0000000117fb6d5b: movabs $0x76aca2a48,%rsi ; {oop(a 'java/lang/Class' = 'com/llk/kt/V')}
0x0000000117fb6d65: mov $0x1,%edi
0x0000000117fb6d6a: mov %edi,0x68(%rsi)
0x0000000117fb6d6d: lock addl $0x0,(%rsp) ;*putstatic a_value
; - com.llk.kt.V::a_test@1 (line 7)
0x0000000117fb6d72: add $0x30,%rsp
0x0000000117fb6d76: pop %rbp
0x0000000117fb6d77: test %eax,-0x946ec7d(%rip) # 0x000000010eb48100
; {poll_return}
0x0000000117fb6d7d: retq
0x0000000117fb6d7e: mov %rsi,0x8(%rsp)
0x0000000117fb6d83: movq $0xffffffffffffffff,(%rsp)
0x0000000117fb6d8b: callq 0x0000000117fb80a0 ; OopMap{off=112}
;*synchronization entry
; - com.llk.kt.V::a_test@-1 (line 7)
; {runtime_call}
0x0000000117fb6d90: jmp 0x0000000117fb6d5b
0x0000000117fb6d92: nop
0x0000000117fb6d93: nop
0x0000000117fb6d94: mov 0x290(%r15),%rax
0x0000000117fb6d9b: movabs $0x0,%r10
0x0000000117fb6da5: mov %r10,0x290(%r15)
0x0000000117fb6dac: movabs $0x0,%r10
0x0000000117fb6db6: mov %r10,0x298(%r15)
0x0000000117fb6dbd: add $0x30,%rsp
0x0000000117fb6dc1: pop %rbp
0x0000000117fb6dc2: jmpq 0x0000000117f230e0 ; {runtime_call}
...
//关键指令=============================
0x0000000117fb6d6d: lock addl $0x0,(%rsp) ;*putstatic a_value
; - com.llk.kt.V::a_test@1 (line 7)
详情可看:
Java代码查看汇编代码方法:Java代码转汇编代码的方法
已经有了缓存一致性协议,为什么还需要volatile
1、并不是所有的硬件架构都提供了相同的一致性保证,Java作为一门跨平台语言,JVM需要提供一个统一的语义。
2、操作系统中的缓存和JVM中线程的本地内存并不是一回事,通常我们可以认为:MESI可以解决缓存层面的可见性问题。使用volatile关键字,可以解决JVM层面的可见性问题。
3、缓存可见性问题的延伸:由于传统的MESI协议的执行成本比较大。所以CPU通过Store Buffer和Invalidate Queue组件来解决,但是由于这两个组件的引入,也导致缓存和主存之间的通信并不是实时的。也就是说,**缓存一致性模型只能保证缓存变更可以保证其他缓存也跟着改变,但是不能保证立刻、马上执行。**在计算机内存模型中,也是使用内存屏障来解决缓存的可见性问题的(再次强调:缓存可见性和并发编程中的可见性可以互相类比,但是他们并不是一回事儿)。
写内存屏障(Store Memory Barrier)可以促使处理器将当前store buffer(存储缓存)的值写回主存。读内存屏障(Load Memory Barrier)可以促使处理器处理invalidate queue(失效队列)。进而避免由于Store Buffer和Invalidate Queue的非实时性带来的问题。
内存屏障也是保证可见性的重要手段,操作系统通过内存屏障保证缓存间的可见性,JVM通过给volatile变量加入内存屏障保证线程之间的可见性。
Java concurrent包中的Atomic类
Atomic类是一系列线程安全的类。
以AtomicInteger为例,内部也是依靠volatile修饰变量来保证线程安全的。
public class AtomicInteger extends Number implements java.io.Serializable {
//...
private volatile int value;
public final int get() {
return value;
}
public final void set(int newValue) {
value = newValue;
}
//...
}
Atomic类怎么支持原子性
那为什么它以原子命名呢?它支持原子性?没错它的确支持原子性。
大家都知道++value、–value等操作都不是原子性操作,所有为了保证value自增、自减操作的原子性,Atomic类特意提供这一类的方法
//AtomicInteger.java
public class AtomicInteger extends Number implements java.io.Serializable {
//...
private volatile int value; //value变量
private static final sun.misc.Unsafe U = sun.misc.Unsafe.getUnsafe(); //直接操作内存的类
private static final long VALUE;
static {
try {
VALUE = U.objectFieldOffset //该方法返回value变量的内存地址偏移量
(AtomicInteger.class.getDeclaredField("value"));
} catch (ReflectiveOperationException e) {
throw new Error(e);
}
}
//自增方法
public final int incrementAndGet() {
return U.getAndAddInt(this, VALUE, 1) + 1;
}
//自减方法
public final int decrementAndGet() {
return U.getAndAddInt(this, VALUE, -1) - 1;
}
//...
}
//sun.misc.Unsafe#getAndAddInt
//其中getIntVolatile、compareAndSwapInt为native方法
public final int getAndAddInt(Object o, long offset, int delta) {
int v;
do {
//getIntVolatile是根据Object的内存地址以及地址偏移量,获取内存中value值的实际值
v = getIntVolatile(o, offset);
/*
compareAndSwapInt(Object obj, long offset, int expect, int update) 就是经典的CAS算法
通过CAS算法,更新value值
*/
} while (!compareAndSwapInt(o, offset, v, v + delta));
return v;
}
//sun.misc.Unsafe#getIntVolatile
public native int getIntVolatile(Object obj, long offset);
//sun.misc.Unsafe#compareAndSwapInt
public native boolean compareAndSwapInt(Object obj, long offset, int expectedValue, int newValue);
CAS是什么
CAS(Compare and Swap 比较并交换)是乐观锁的一种实现方式。同时CAS也是一种非阻塞算法的常见实现方式。
当多个线程尝试使用CAS同时更新同一个变量时,只有其中一个线程能更新变量的值,而其它线程都失败,失败的线程并不会被挂起,而是被告知这次竞争中失败,并可以再次尝试。
CAS操作中包含三个参数(V,E,N),V表示要更新的变量(也就是从主存中拷贝过来的值)、E表示预期的值、N表示新值,当V == E的情况下,将N赋值给V。
//CAS操作的伪代码
do{
e = v;
}while(!compareAndSwapInt(v, e, e+1))
boolean compareAndSwapInt(v, e, u){
if(v == e){
v = u;
return true;
}
return false;
}
/*
主存变量v=10,两个线程t1(期望值e1)、t2(期望值e2),两个线程都以自增v为目的。
在并发环境下,t1线程拿到了执行权,执行1(e1=v),执行2(v==e1),执行3(v=e1+1,v已经是11了)。
当t1线程走执行2的时候,t2线程执行e2=v(v依旧是10),在t1线程执行完后,t2线程继续执行,走到v==e2发现不成立,因为v已经变成了11,t2线程需要循环执行,直到v==e2成立为止。
*/
实际上CAS实现是通过操作指令来完成的,如下面代码所示,程序会根据当前处理器的类型来决定是否为cmpxchg指令添加lock前缀。如果程序是在多处理器上运行,就为cmpxchg指令加上lock前缀(lock cmpxchg)。反之,如果程序是在单处理器上运行,就省略lock前缀(单处理器自身会维护单处理器内的顺序一致性,不需要lock前缀提供的内存屏障效果)。
#define LOCK_IF_MP(mp) __asm cmp mp, 0 \
__asm je L0 \
__asm _emit 0xF0 \
__asm L0:
inline jint Atomic::cmpxchg (jint exchange_value, volatile jint* dest, jint compare_value) {
// alternative for InterlockedCompareExchange
int mp = os::is_MP();
__asm {
mov edx, dest
mov ecx, exchange_value
mov eax, compare_value
LOCK_IF_MP(mp)
cmpxchg dword ptr [edx], ecx
}
}
CAS有什么缺陷
只能保证单个共享变量的原子性
问题原因:
当对一个共享变量执行操作时可以使用CAS的方式来保证原子操作,但是对多个共享变量操作时,CAS就无法保证操作的原子性。
解决方法:
① AtomicReference类来保证引用对象之间的原子性,可以把多个变量放在这一个对象里;
② 用其他的锁。
高并发下,自旋开销大
问题原因:
CAS 操作在出现线程竞争时,失败的线程会白白地循环一次,在高并发情况下,因为每次CAS都只有一个线程能成功,竞争失败的线程会非常多。失败次数越多,循环次数就越多,很多线程的CAS操作越来越接近自旋锁(spin lock)。
计数操作本来是一个很简单的操作,实际需要耗费的cpu时间应该是越少越好,在高并发计数时,大量的cpu时间都浪费会在自旋上了,这会降低了实际的计数效率。
解决方法:
java8新增的LongAdder/DoubleAdder累加器,专门为高并发设计的计数器。
LongAdder是根据ConcurrentHashMap这类为并发设计的类的基本原理锁分段,来实现的。
并发计数时,不同的线程各自维护着自己的计数器,在执行sum()时候才会同步值(在获取值的时候内部就会先调用sum()再返回值)。这样减少了线程竞争,提高了并发效率。本质上是用空间换时间的思想,不过在实际高并发情况中消耗的空间可以忽略不计。
ABA问题
问题原因:
CAS只关心结果,不在乎过程。
比如一个int从100变成101再变成100,在这个过程int的值已经发生变化了,并不是初始值了。但是CAS是无法感知的,CAS过程中只简单进行了“值”的校验。在实际使用中如果依赖变化过程的话,ABA问题会是一个隐患。
AtomicInteger atomicInteger = new AtomicInteger(100);
new Thread(new Runnable() {
@Override
public void run() {
atomicInteger.compareAndSet(100, 101);
System.out.println("t1 === " + atomicInteger.get());
atomicInteger.compareAndSet(101, 100);
System.out.println("t1 === " + atomicInteger.get());
}
}).start();
new Thread(new Runnable() {
@Override
public void run() {
try {
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
atomicInteger.compareAndSet(100, 10086);
System.out.println("t2 === " + atomicInteger.get());
}
}).start();
/* 输出 ->
t1 === 101
t1 === 100
t2 === 10086
*/
解决方法:引入“版本号”,每个数据对应一个版本,版本变化即使值相同,也不会校验通过。
java5 AtomicStampedReference就可以解决ABA问题。
AtomicStampedReference<Integer> atomicInteger = new AtomicStampedReference(100, 1);
new Thread(new Runnable() {
@Override
public void run() {
atomicInteger.compareAndSet(100, 101, 1, 2);
System.out.println("t1 === " + atomicInteger.getReference());
atomicInteger.compareAndSet(101, 100, 2, 3); //值虽然变回了100,但是版本号变成3了
System.out.println("t1 === " + atomicInteger.getReference());
}
}).start();
new Thread(new Runnable() {
@Override
public void run() {
try {
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
//传入错误的版本号,是校验不通过的。
atomicInteger.compareAndSet(100, 10086, 1, 4);
System.out.println("t2 === " + atomicInteger.getReference());
}
}).start();
/* 输出 ->
t1 === 101
t1 === 100
t2 === 100 由于版本号不对,校验不通过,值替换失败
*/