1. 相关概念
- 本地缓存:程序运行时,为了提高运行的速度,CPU可以不直接跟内存进行通信,而是先将内存中的数据读到内部缓存,然后再进行操作。这样会提高效率,但是我们不知道本地缓存中的修改何时会回写到共享内存中;
- 内存可见性:可见性的意思是当一个线程修改了一个共享变量时,其他的线程能够读到这个修改的值。(这句话我当初理解的有问题,导致我在修改的代码的时候掉进一个很大的坑里) ;
-
共享内存:JVM在执行Java程序时会 把它管理的内存划分成几个不同的数据区域。这些区域都有着各自的用途,以及创建和销毁的时间。具体其中有部分数据区域是所有线程共享的,这部分数据区域称之为共享内存,共享内存中的变量叫做共享变量。
其中堆是最大的一块共享内存,也是垃圾收集器重点管理的区域。基本上我们所有的实例对象都是在堆上创建的。而方法区主要是用来存储加载过的类信息、常量和静态变量等。内存可见性就是针对于共享内存而言的。 - 缓存一致性协议:简单来说,就是在多核CPU中一个共享变量被其中一个线程修改且回写到内存中之后,其他的CPU中缓存的这个共享变量就会被置为invalid。在下次读的时候就会更新这个缓存。
- 原子操作:不可被中断的一个或一系列操作。
2. 内存可见性的相关实现
2.1 volatile
volatile相较于synchronized而言,使用的代价和成本更低,因为它不会因为线程上下文的切换和调度。
volatile的实现原理
当我们使用volatile关键字修饰一个变量之后,在程序运行时,它会导致以下两件事:
- 本地缓存的数据会立马回写到共享内存中;
- 这个回写会导致其他CPU缓存中这个内存地址对应的数据无效,在下次读的时候就需要更新本地缓存。
volatile的内存语义
使用volatile 修饰变量,实际是就对变量的单个读/单个写做了同步。相信很多人都知道volatile++不是原子操作。下面我们通过一些等效代码来分析原因。
单个读写
首先我们来理解一下单个读写是什么意思。
public class AtomicVariable {
volatile int v;
//单次读
public int getA() {
return v;
}
//单次写
public void setA(int v) {
this.v = v;
}
}
这里的代码等效于:
public class AtomicVariable {
volatile int v;
//单次读
public synchronized int getA() {
return v;
}
//单次写
public synchronized void setA(int v) {
this.v = v;
}
}
通过以上的代码我们可以看出来,针对于volatile修饰的变量,单次的读写,其原子性是可以得到保证的。也就是说,在对于单个线程而言,当读volatile变量时,这个变量肯定是最近修改的值。在写这个变量时,它可以保证下次线程读的这个值是最新的。那对于volatile++的操作为什么不行呢? 首先我们得明白volatile++是一个复合操作,它可以转换成volatile = volatile + 1。即先读了volatile,然后进行加1操作,在写给volatile。这里只有第一步和第三步可以保证原子性,而第二步做不到。我们可以通过代码展示一下。
public class AtomicVariableTest {
public volatile int a = 0;
@Test
public void atomicOperation() throws InterruptedException {
List<Thread> threads = new ArrayList<Thread>();
for (int i = 0; i < 4; i++) {
threads.add(new Thread(() -> {
for (int j = 0; j < 100000; j++) {
int tmp = a + 1;
a = tmp;
}
}));
}
for(Thread t: threads){
t.start();
}
for(Thread t: threads){
t.join();
}
}
}
399748
399749
399750
399751
399752
399753
399754
399755
399756
399757
从结果来看,这里的读写操作并没有得到同步。
2.2 Synchronized
synchronized主要是通过锁来实现同步的,而在java中每一个多对象都可以作为锁。具体的表现有以下几种形式:
- 对于普通同步方法,锁是当前实例对象;
public synchronized void set(int v) {
...
}
- 对于静态同步方法,锁是当前类的Class对象;
public synchronized static void staticMethod(){
...
}
- 对于同步方法块, 所以Synchronized括号里配置的对象。
Object ob = new Object();
synchronized(ob){
....
}
当一个线程试图运行同步代码块时,它必须先获得锁。在同步代码块执行完之后或抛出异常之后,它必须释放锁。
synchronized用的锁是存储在对象头中的,而锁的状态又分为以下几种:
- 重量级锁状态
- 轻量级锁状态
- 偏向锁状态
- 无锁状态
锁的级别依次递减,锁可以升级但是不可以降级。关于这几种状态,与本文主题没有太大关联,感兴趣的小伙伴可以再去了解。
synchronized可以保证同步方法或同步代码块一次只能由一个线程访问执行,同时能够保证对共享变量操作的可见性。更改上面的代码:
public class AtomicVariableTest {
public int a = 0;
@Test
public void atomicOperation() throws InterruptedException {
List<Thread> threads = new ArrayList<Thread>();
for (int i = 0; i < 4; i++) {
threads.add(new Thread(() -> {
for (int j = 0; j < 100000; j++) {
increment();
System.out.println(Thread.currentThread().getName() + " " + a);
}
}));
}
for(Thread t: threads){
t.start();
}
for(Thread t: threads){
t.join();
}
}
public synchronized void increment(){
a++;
}
}
这里的代码便得到了同步,结果为400000.
小结:上面大概说了一下volatile与sychronized,现在做一下总结。首先,相同点大致有两点
- 二者都可以保证可见性,即当前线程的修改,对其他线程下次的读写可见;
- 二者对指令的重排序都有一定的限制。
而不同点则有以下几个方面:
- sychronized 可以做到同步(操作的原子性),而仅靠volatile做不到;
- sychronized 可以修饰代码块或者方法,而volatile只能修饰单个变量;
- volatile不会造成线程阻塞,而synchronzied会。
3. 原子操作的实现原理
CPU实现原子操作主要有两种方式:
1. 总线加锁
2. 缓存加锁
3.1 使用总线锁保证原子性
我们通过上面的图理解一下总线锁是如何实现原子性的。假设现在共享内存中有一个值a = 1,CPU1到CPU3都要执行
int tmp =a + 1; a = tmp;
这样的操作。那么共享内存中的a就会被多个处理器同时进行操作。这样的多改写操作就不是原子性的,操作完之后的值可能不是我们想要的。原因很简单,多个CPU都从自己的本地缓存中读取了变量a,然后进行改写操作,而本地缓存中的值可能还是旧值。改写完之后再分别回写,那么内存中值就是最后一次写的。而总线锁就是保证了一个CPU在对共享内存中的变量进行读改写操作时,其他的CPU不能操作缓存了该共享变量内存地址的缓存。上列中,就是一个CPU对共享变量a进行了读改写操作,其他的CPU就不能操作本地缓存中的a。
总线锁就是在CPU1对变量a进行读改写操作时,会在总线输出一个LOCK #信号,之后共享内存就会被CPU1独占,其他处理器的总线请求都会被阻塞。这样子就可以保证对变量的读改写操作是原子性的了。但是使用这种方式会导致对于部分变量的读改写,会让其他CPU无法读写共享内存中的其他变量,开销过大。
3.2 使用缓存锁来保证原子性
其实对于一些频繁操作的内存,各个CPU会将其缓存在本地缓存中。那么对于这些内存或变量,我们只要本证他的读改写在各个CPU的缓存之间是原子性的即可,并不需要声明总线锁,带来过大的开销。
缓存锁是通过内存一致性协议来实现的,即当某个CPU修改了共享内存中个某个变量并回写之后,其他CPU会让缓存了这块内存数据的缓存失效,在下次读的时候就是最新的值了。
4. Java实现原子操作
所谓原子操作就是指一个线程在执行一系列操作的时候,需要保证其使用的共享变量不会给其他的线程读改写。Java实现原子操作的方式有两种,一个是使用锁,另一个是使用循环CAS。
4.1 使用循环CAS来实现原子操作
Java的中的CAS(Compare And Swap)操作可以保证操作的原子性。CAS操作就是用期望值跟旧值进行比较,如果相同则会将旧值更新成新值,且保证这一过程的原子性。根据这一特性,Java可以实现操作的原子性。且看先下面的Demo:
public class CommonTest {
private AtomicInteger a = new AtomicInteger(0);
@Test
public void test() throws InterruptedException {
List<Thread> threads = new ArrayList<Thread>();
for (int i = 0; i < 3; i++) {
threads.add(new Thread(new CounterRunnable()));
}
for (Thread t : threads) {
t.start();
}
for (Thread t : threads) {
t.join();
}
}
class CounterRunnable implements Runnable {
@Override
public void run() {
for (int i = 0; i < 100000; i++) {
increment();
}
}
private void increment() {
while (true) {
int i = a.get();
boolean changed = a.compareAndSet(i, ++i);
if (changed) {
System.out.println(a.get());
break;
}
}
}
}
}
输出结果为300000
相较于锁,CAS可以高效地实现原子操作,但是在使用它的时候也得注意一些可能遇到的问题。
CAS 实现原子操作可能遇到的三大问题
- ABA问题:ABA问题就是当状态连续改变之后,CPU可能感知不到,例如变量从A到B,然后再从B变成A。这个时候,实际上变量是改变过了,但是CAS进行检查的时候会发现他的值并没有改变。
- 循环开销过大:在上面的代码样例中,如果一直比较失败的话,线程会一直在那里执行,资源也不会释放,这样的开销是比较大的;
- 只能保证一个变量的原子操作:由CAS的使用方式,我们可以看出来CAS只能对单个变量进行原子性的操作。
4.2 通过锁实现原子操作
Java锁机制保证了只有获得了锁的线程才能执行锁定的内存区域,同时能保证操作的原子性。但是相比较与CAS而言,一般情况下他的开销更大。使用时需要慎重。
参考:
- 《Java 并发编程的艺术》 方腾飞,魏鹏,程晓明
- 《深入理解Java虚拟机》 陶志华