Java高并发--原子性可见性有序性
主要是学习慕课网实战视频《Java并发编程入门与高并发面试》的笔记
- 原子性:指一个操作不可中断,一个线程一旦开始,直到执行完成都不会被其他线程干扰。换句话说原子性保证了任何时刻只有一个线程在对共享变量进行操作。
- 可见性:指当一个线程修改了某个共享变量的值,其他线程是否能立即知道这个修改。
- 有序性:一个线程观察其他线程中的指令,由于指令重排序的存在,该观察结果一般杂乱无序
原子性
AtomicInteger
JDK的atomic包下提供了许多“原子类”,它们都是基于CAS操作实现的。
所谓CAS(Compare And Swap),即“比较并交换”。CAS基于乐观的态度,是无锁操作,它操作包含三个参数,当前要更新的变量、期望值、新值,仅当:当前值和预期值一样时,才会将当前值设置为新值;如果当前值和预期值不一样,说明这个变量已经被其他线程修改过了。如果有多个线程同时使用CAS操作一个变量时,只有一个会胜出,并成功更新。
以atomic包中最常用的AtomicInteger为例,追踪其getAndIncrement()
,
public final int getAndIncrement() {
return unsafe.getAndAddInt(this, valueOffset, 1);
}
可以看到它调用了unsafe.getAndAddInt(this, valueOffset, 1)
public final int getAndAddInt(Object var1, long var2, int var4) {
int var5;
do {
var5 = this.getIntVolatile(var1, var2);
} while(!this.compareAndSwapInt(var1, var2, var5, var5 + var4));
return var5;
}
其中var1表示要被更新的对象,var2是原始值在内存中的偏移地址。通过getIntVolatile(var1, var2);
拿到现在的值var5。但多个线程修改下,内存中的原始值随时都可能变化,所以现在var5是一个期望值(期望内存中的值和刚读取到的var5是相等的,因为内存中的值此时可能已经变了)。compareAndSwapInt
是一个native方法,compareAndSwapInt(var1, var2, var5, var5 + var4)
这句的意思是对于var1
对象,根据偏移地址var2
拿到的内存中的原始值,如果和期望值var5
相等,则将其更新为var5 + var4
。同时从while-do也可以直到,该方法在一直尝试,直到内存中的值和期望值一样时,才能进行修改,并返回修改前内存中的值。
这里只是举例解释了其中一个方法,其他方法的实现大同小异,总之都是使用了CAS操作来保证线程安全。
类似的还有AtomicLong,AtomicBoolean,值得一提的还有有一个compareAndSet
方法,当且仅当期望值except和内存中的值相等时,才会执行更新操作。可以保证在多线程下同时修改共享变量,只有一个线程可以修改成功。
public final boolean compareAndSet(int expect, int update) {
return unsafe.compareAndSwapInt(this, valueOffset, expect, update);
}
举个例子
package com.shy.concurrency.count;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;
/**
* @author Haiyu
* @date 2018/12/17 17:33
*/
public class Test {
public static void main(String[] args) {
AtomicInteger a = new AtomicInteger(9);
ExecutorService executorService = Executors.newFixedThreadPool(10);
for (int i = 0;i < 10; i++) {
executorService.execute(()-> {
if (a.compareAndSet(9, 10)) {
System.out.println("更新成功");
System.out.println(a.get());
}
});
}
executorService.shutdown();
}
}
上面有十个线程要修改a
的值,但是最后程序只打印了一次10。因为一旦某个程序成功将a更新成10,其他线程的期望值except就和现在内存中的值10不相等了,所以都会更新失败。
LongAdder
像AtomicInteger等原子类使用CAS操作虽然没有锁,但是也可以使用减小锁粒度这种分离热点的思想。LongAdder正是这样的类,它也位于atomic包下,且有着比AtomicInteger等原子类更好的性能。LongAdder有一个称为base的变量,如果在多线程下对base的修改没有发生冲突的话,会直接操作base变量;但是如果发生了冲突,base这个热点数据会被分离成多个单元cell,每个单元独立维护内部的值(通过哈希算法定位到数组中的某个cell)。这个对象的值其实是由cell数组的求和累加得到的,这样热点就进行了有效的分离,提高了并行度。
AtomicReference
AtomicReference和AtomicInteger十分相似,不过一个是对整数的封装,一个是对普通对象的封装。
AtomicReference money = new AtomicReference<>();
这样写就行了,泛型类型是Integer,因此可以实现和AtomicInteger相同的功能。
AtomicStampedReference
CAS可能引发"ABA"问题,即一个变量原来是A,先被修改成B后又修改回了A,由于CAS操作只是比较当前值和预期值是否一样(只比较结果,不在乎过程中状态的变化),在其他线程来看,该变量就好像没有发生过变化。
可以为数据添加时间戳,每次成功修改数据时,不仅更新数据的值,同时要更新时间戳的值。CAS操作时,不仅要比较当前值和预期值,还要比较当前时间戳和预期时间戳。两者都必须满足预期值才能修改成功。
AtomicStampedReference正是这样做的,它不仅维护对象值,还维护了一个时间戳(其实可就是一个版本号)。当AtomicStampedReference对应的值被修改时,不仅要更新数据本身,还要更新时间戳(版本号)。只有当数据本身和时间戳都满足期望值,写入才会成功。因此,虽然对象值被反复修改又被更新成了原来的值,但是时间戳发生了变化,就可以防止不恰当的写入。
AtomicIntegerFieldUpdater
该类可以使普通变量也拥有原子操作。首先保证该普通变量是volatile的(且不能有static修饰符)。然后像下面这样使用。
AtomicIntegerFieldUpdater updater = AtomicIntegerFieldUpdater.newUpdater(Money.class, "money");
其中Money类中有一个“money”的字段,它的类型是普通的int型。通过上面的用法,使得Money中的money字段也拥有的原子性。
class Money {
volatile int money;
public int getMoney() {
return money;
}
}
运行以下程序,将总得到打印值为10,说明这是线程安全的
package com.shy.concurrency.count;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicIntegerFieldUpdater;
import java.util.concurrent.atomic.AtomicReference;
/**
* @author Haiyu
* @date 2018/12/17 17:33
*/
@ThreadSafe
public class Test {
private volatile int count;
public int getCount() {
return count;
}
static AtomicIntegerFieldUpdater updater = AtomicIntegerFieldUpdater.newUpdater(Money.class, "money");
public static void main(String[] args) throws InterruptedException {
final Money money = new Money();
CountDownLatch cdl = new CountDownLatch(10);
ExecutorService executorService = Executors.newFixedThreadPool(10);
for (int i = 0; i < 10; i++) {
executorService.execute(() -> {
updater.incrementAndGet(money);
cdl.countDown();
});
}
cdl.await();
System.out.println(money.getMoney());
executorService.shutdown();
}
}
AtomicIntegerArray
除了普通对象、基本数据类型的包装类,atomic包还提供了原子数组。如AtomicIntegerArray,AtomicLongArray,在使用上无非就是加上了索引。
比如
public final boolean compareAndSet(int i, int expect, int update) {...}
i
就是数组的索引,其他API也类似,需要指定一个索引来明确表明要对哪一个变量进行原子操作。
锁
Java中有synchronized和重入锁来保证线程的同步,以实现线程安全。
synchronized是JVM的内置锁,而重入锁是Java代码实现的。重入锁是synchronized的扩展,可以完全代替后者。重入锁可以重入,允许同一个线程连续多次获得同一把锁。其次,重入锁独有的功能有:
- 可以相应中断,synchronized要么获得锁执行,要么保持等待。而重入锁可以响应中断,使得线程在迟迟得不到锁的情况下,可以不再等待。主要由
lockInterruptibly()
实现,这是一个可以对中断进行响应的锁申请动作,锁中断可以避免死锁。 - 锁的申请可以有等待时限,用
tryLock()
可以实现限时等待,如果超时还未获得锁会返回false,也防止了线程迟迟得不到锁时一直等待,可避免死锁。 - 公平锁,即锁的获得按照线程先来后到的顺序依次获得,不会产生饥饿现象。synchronized的锁默认是不公平的,重入锁可通过传入构造方法的参数实现公平锁。
- 重入锁可以绑定多个Condition条件,这些condition通过调用await/singal实现线程间通信。
synchronized可以作用在如下四个地方:
- 代码块,使用当前对象或其他任意对象作为锁。被代码块包围的代码会同步执行。
synchronized(this)
和synchronized(obj)
就分别使用自身和obj对象作为锁。 - 修饰方法,使用当前对象作为锁。整个方法会同步执行
- 修饰静态方法,使用类作为锁(因此作用于该类的所有对象)。整个方法会同步执行
注意在多线程下如果要保证synchronized的线程安全,必须使用同一把锁。
可见性
导致可见性的原因:
- 线程交叉执行
- 指令重排结合线程交叉执行
- 共享变量更新后没有在工作内存和主内存之间及时更新
synchronized的可见性
- 线程解锁前,必须将共享变量的最新值刷回主内存中。
- 线程加锁时,将清空工作内存中共享变量的值,从而使用共享变量时需要从主内存中重新读取最新的值(注意加锁与解锁使用同一个锁)。
volatile的可见性
- 对volatile变量的写操作,会在写操作后加入一条store屏障指令,将工作内存中的共享变量值刷新到主内存中;
- 对volatile变量的读操作,会在读操作前加入一条load屏障指令,读主内存中读取共享变量
换句话说,volatile的作用是:在本CPU对变量的修改直接写入主内存中,同时这个写操作使得其他CPU中对应变量的缓存行无效,这样其他线程在读取这个变量时候必须从主内存中读取,所以读取到的是最新的,这就是上面说得能被立即“看到”。
volatile常使用于标志位的判断。
volatile boolean ready= false;
// 线程1
config = loadConfig(); // 语句1
ready = true; // 语句2
// 线程2,ready为true时才停止sleep()
while (!ready) {
sleep();
}
runWithConfig(config);
如上面的例子,如果ready变量不是volatile的,有可能因为指令重排,先执行语句2再执行语句1。由于先执行语句2,那么线程2中再config还没有初始化时就执行了runWithConfig(config),这显然是不合理的。当ready加上了volatile修饰符,禁止了指令重排,因此不会发生以上情况。
有序性
Happen-Before规则
有些指令是可以重排的,有些指令是不可重排的。下面是一些基本原则:
- 程序顺序原则:一个线程内保证语义的串行性,比如第二条语句依赖第一条语句的结果,那么就不能先执行第二条再执行第一条。
- volatile原则:volatile变量的写先于读,着保证了volatile变量的可见性
- 锁规则:先解锁,后续步骤再加锁。加锁不能重排到解锁之前,这样加锁行为无法获得锁(刚加上就解了)
- 传递性:A先于B,B先于C,那么A先于C
- 线程的
start()
先于它的每个动作 - 线程的所有操作先于线程的终结(可以通过
Tread.join()
方法结束、Thread.isAlive()
的返回值判断一个线程是否终结) - 线程的中断(
interrupt()
)先于被中断线程的代码 - 对象的构造函数执行、结束先于
finalize()
方法。