1. 什么是缓存一致性问题?如何解决呢?
2. 简述 volatile 关键字(或 volatile 的内存语义或 volatile 的2个特性)
3. 简述 JAVA 的内存模型。
4. Java 中的同步容器类和缺陷
5. 为什么说 ConcurrentHashMap 是弱一致性的?以及为何多个线程并发修改 ConcurrentHashMap 时不会报 ConcurrentModificationException?
6. CopyOnWriteArrayList 的实现原理。
7. Java 中堆和栈有什么不同?
8. Java 中的活锁,死锁,饥饿有什么区别?
当程序在运行过程中,会将运算需要的数据从主存复制一份到CPU 的高速 缓存当中,那么 CPU 进行计算时就可以直接从它的高速缓存读取数据和向其中 写入数据,当运算结束之后,再将高速缓存中的数据刷新到主存当中。举个简 单的例子,比如下面的这段代码:
i = i+1
当线程执行这个语句时,会先从主存当中读取 i 的值,然后复制一份到高
速缓存当中,然后 CPU 执行指令对 i 进行加 1 操作,然后将数据写入高速缓存,
最后将高速缓存中 i 最新的值刷新到主存当中。
这个代码在单线程中运行是没有任何问题的,但是在多线程中运行就会有
问题了。在多核 CPU 中,每条线程可能运行于不同的 CPU 中,因此每个线程
运行时有自己的高速缓存(对单核 CPU 来说,其实也会出现这种问题,只不过
是以线程调度的形式来分别执行的)。本文我们以多核 CPU 为例。
比如同时有 2 个线程执行这段代码,假如初始时 i 的值为 0,那么我们希望
两个线程执行完之后 i 的值变为 2。但是事实会是这样吗?
可能存在下面一种情况:初始时,两个线程分别读取 i 的值存入各自所在的
CPU 的高速缓存当中,然后线程 1 进行加 1 操作,然后把 i 的最新值 1 写入到
内存。此时线程 2 的高速缓存当中 i 的值还是 0,进行加 1 操作之后,i 的值为
1,然后线程 2 把 i 的值写入内存。
最终结果 i 的值是 1,而不是 2。这就是著名的缓存一致性问题。通常称这
种被多个线程访问的变量为共享变量。
也就是说,如果一个变量在多个 CPU 中都存在缓存(一般在多线程编程
时才会出现),那么就可能存在缓存不一致的问题。
为了解决缓存不一致性问题,通常来说有以下 2 种解决方法:
1)通过在总线加 LOCK#锁的方式
2)通过缓存一致性协议
在早期的 CPU 当中,是通过在总线上加 LOCK#锁的形式来解决缓存不一
致的问题。因为 CPU 和其他部件进行通信都是通过总线来进行的,如果对总线
加 LOCK#锁的话,也就是说阻塞了其他 CPU 对其他部件访问(如内存),
从而使得只能有一个 CPU 能使用这个变量的内存。比如上面例子中 如果一个
线程在执行 i = i +1,如果在执行这段代码的过程中,在总线上发出了 LCOK#
锁的信号,那么只有等待这段代码完全执行完毕之后,其他 CPU 才能从变量 i
所在的内存读取变量,然后进行相应的操作。这样就解决了缓存不一致的问题。
但是上面的方式会有一个问题,由于在锁住总线期间,其他 CPU 无法访问
内存,导致效率低下。
所以就出现了缓存一致性协议。该协议保证了每个缓存中使用的共享变量
的副本是一致的。它核心的思想是:当 CPU 向内存写入数据时,如果发现操作
的变量是共享变量,即在其他 CPU 中也存在该变量的副本,会发出信号通知其
他 CPU 将该变量的缓存行置为无效状态,因此当其他 CPU 需要读取这个变量
时,发现自己缓存中缓存该变量的缓存行是无效的,那么它就会从内存重新读
取。
一旦一个共享变量(类的成员变量、类的静态成员变量)被 volatile 修饰
之后,那么就具备了两层语义:
1)保证了不同线程对这个变量进行读取时的可见性,即一个线程修改
了某个变量的值,这新值对其他线程来说是立即可见的。(volatile 解决了
线程间共享变量的可见性问题)。
第一:使用 volatile 关键字会强制将修改的值立即写入主存;
第二:使用 volatile 关键字的话,当线程 2 进行修改时,会导致线程 1 的
工作内存中缓存变量 stop 的缓存行无效(反映到硬件层的话,就是 CPU 的 L1
或者 L2 缓存中对应的缓存行无效);
第三:由于线程 1 的工作内存中缓存变量 stop 的缓存行无效,所以线程 1
再次读取变量 stop 的值时会去主存读取。
那么,在线程 2 修改 stop 值时(当然这里包括 2 个操作,修改线程 2 工
作内存中的值,然后将修改后的值写入内存),会使得线程 1 的工作内存中缓
存变量 stop 的缓存行无效,然后线程 1 读取时,发现自己的缓存行无效,它会
等待缓存行对应的主存地址被更新之后,然后去对应的主存读取最新的值。
那么线程 1 读取到的就是最新的正确的值。
2)禁止进行指令重排序,阻止编译器对代码的优化。
volatile 关键字禁止指令重排序有两层意思:
I)当程序执行到 volatile 变量的读操作或者写操作时,在其前面的操作的
更改肯定全部已经进行,且结果已经对后面的操作可见;在其后面的操作肯定
还没有进行;
II)在进行指令优化时,不能把 volatile 变量前面的语句放在其后面执行,
也不能把 volatile 变量后面的语句放到其前面执行。
为了实现 volatile 的内存语义,加入 volatile 关键字时,编译器在生成字节码时,
会在指令序列中插入内存屏障,会多出一个 lock 前缀指令。内存屏障是一组处理器指
令,解决禁止指令重排序和内存可见性的问题。编译器和 CPU 可以在保证输出结果一
样的情况下对指令重排序,使性能得到优化。处理器在进行重排序时是会考虑指令之间
的数据依赖性。
内存屏障,有 2 个作用:1.先于这个内存屏障的指令必须先执行,后于这个内存屏障
的指令必须后执行。2.使得内存可见性。所以,如果你的字段是 volatile,在读指令前插入
读屏障,可以让高速缓存中的数据失效,重新从主内存加载数据。在写指令之后插入写屏
障,能让写入缓存的最新数据写回到主内存。
lock 前缀指令在多核处理器下会引发了两件事情:
1.将当前处理器中这个变量所在缓存行的数据会写回到系统内存。这个写回内存的
操作会引起在其他 CPU 里缓存了该内存地址的数据无效。但是就算写回到内存,如果
其他处理器缓存的值还是旧的,再执行计算操作就会有问题,所以在多处理器下,为了
保证各个处理器的缓存是一致的,就会实现缓存一致性协议,每个处理器通过嗅探在总
线上传播的数据来检查自己缓存的值是不是过期了,当处理器发现自己缓存行对应的内
存地址被修改,就会将当前处理器的缓存行设置成无效状态,当处理器要对这个数据进
行修改操作的时候,会强制重新从系统内存里把数据读到处理器缓存里。
2.它确保指令重排序时不会把其后面的指令排到内存屏障之前的位置,也不会把前面
的指令排到内存屏障的后面;即在执行到内存屏障这句指令时,在它前面的操作已经全
部完成。
内存屏障可以被分为以下几种类型:
LoadLoad 屏障:对于这样的语句 Load1; LoadLoad; Load2,在 Load2 及后续读取
操作要读取的数据被访问前,保证 Load1 要读取的数据被读取完毕。
StoreStore 屏障:对于这样的语句 Store1; StoreStore; Store2,在 Store2 及后续写
入操作执行前,保证 Store1 的写入操作对其它处理器可见。
LoadStore 屏障:对于这样的语句 Load1; LoadStore; Store2,在 Store2 及后续写
入操作被刷出前,保证 Load1 要读取的数据被读取完毕。
StoreLoad 屏障:对于这样的语句 Store1; StoreLoad; Load2,在 Load2 及后续所
有读取操作执行前,保证 Store1 的写入对所有处理器可见。它的开销是四种屏障中最
大的。在大多数处理器的实现中,这个屏障是个万能屏障,兼具其它三种内存屏障的功
能。
扩展:使用 volatile 关键字的场景。
双重校验锁 DCL(double checked locking)--使用 volatile 的场景之一。
区别于“JVM 的内存模型”。
Java 内存模型规定所有的变量都是存在主存当中(类似于前面说的物理内
存),每个线程都有自己的工作内存(类似于前面的高速缓存)。线程对变量
的所有操作都必须在工作内存中进行,而不能直接对主存进行操作,并且每个
线程不能访问其他线程的工作内存。
Java 内 存 模 型 的 Volatile 关 键 字 和 原 子 性 、 可 见 性 、 有 序 性 和
happens-before 关系等。
一.Volatile 关键字 解析见上面。
二.要想并发程序正确地执行,必须要保证原子性、可见性以及有序性。
只要有一个没有被保证,就有可能会导致程序运行不正确。
1.原子性
原子性:即一个操作或者多个操作 要么全部执行并且执行的过程不会被任
何因素打断,要么就都不执行。
可以通过 Synchronized 和 Lock 实现“原子性”。
例题:请分析以下哪些操作是原子性操作。
x = 10;
//语句 1
y = x; //语句 2
x++; //语句 3
x = x + 1; //语句 4
特别注意,在 java 中,只有对除 long 和 double 外的基本类型进行简
单的赋值(如 int a=1)或读取操作,才是原子的。只要给 long 或 double
加上 volatile,操作就是原子的了。
语句 1 是原子性操作,其他三个语句都不是原子性操作。
语句 2 实际上包含 2 个操作,它先要去读取 x 的值,再将 x 的值写入工作
内存,虽然读取 x 的值以及将 x 的值写入工作内存这 2 个操作都是原子性操作,
但是合起来就不是原子性操作了。
同样的,x++和 x = x+1 包括 3 个操作:读取 x 的值,进行加 1 操作,
写入新的值。
2.可见性
可见性是指当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线
程能够立即看得到修改的值。
通过 Synchronized 和 Lock 和 volatile 实现“可见性”。
3.有序性
有序性:即程序执行的顺序按照代码的先后顺序执行。
我的理解就是一段程序代码的执行在单个线程中看起来是有序的。这个应该是程序看
起来执行的顺序是按照代码顺序执行的,因为虚拟机可能会对程序代码进行指令重排序。
虽然进行重排序,但是最终执行的结果是与程序顺序执行的结果一致的,它只会对不存在
数据依赖性的指令进行重排序。因此,在单个线程中,程序执行看起来是有序执行的,这
一点要注意理解。事实上,这个规则是用来保证程序在单线程中执行结果的正确性,但无
法保证程序在多线程中执行的正确性。
三.happens-before 原则(先行发生原则):
- 程序次序规则:一个线程内,按照代码顺序,书写在前面的操作先行发生于书写在 后面的操作
- 锁定规则:一个 unLock 操作先行发生于后面对同一个锁的 lock 操作
- volatile 变量规则:对一个变量的写操作先行发生于后面对这个变量的读操作
- 传递规则:如果操作 A 先行发生于操作 B,而操作 B 又先行发生于操作 C,则可以 得出操作 A 先行发生于操作 C
- 线程启动规则:Thread 对象的 start()方法先行发生于此线程的每个一个动作
- 线程中断规则:对线程 interrupt()方法的调用先行发生于被中断线程的代码检测 到中断事件的发生
- 线程终结规则:线程中所有的操作都先行发生于线程的终止检测,我们可以通过 T hread.join()方法结束、Thread.isAlive()的返回值手段检测到线程已经终止执行
- 对象终结规则:一个对象的初始化完成先行发生于他的 finalize()方法的开始
在 Java 中,同步容器主要包括 2 类:
1)Vector、HashTable。
2)Collections 类中提供的静态工厂方法创建的类。
Collections.synchronizedXXX()。
缺陷:
1.性能问题。
在有多个线程进行访问时,如果多个线程都只是进行读取操作,那么每个
时刻就只能有一个线程进行读取,其他线程便只能等待,这些线程必须竞争同
一把锁。
2.ConcurrentModificationException 异常。
在对 Vector 等容器进行迭代修改时,会报
ConcurrentModificationException 异常。但是在并发容器中(如
ConcurrentHashMap,CopyOnWriteArrayList 等)不会出现这个问
题。
5.为什么说 ConcurrentHashMap 是弱一致性的?以及为何多个 线 程 并 发 修 改 ConcurrentHashMap 时 不 会 报ConcurrentModificationException?
1.ConcurrentHashMap#get()
正 是 因 为 get 操 作 几 乎 所 有 时 候 都 是 一 个 无 锁 操 作 ( get 中 有 一 个
readValueUnderLock 调用,不过这句执行到的几率极小),使得同一个
Segment 实例上的 put 和 get 可以同时进行,这就是 get 操作是弱一致的根
本原因。
2.ConcurrentHashMap#clear()
clear 方法很简单,看下代码即知。
public void clear() {
for(int i= 0; i< segments.length; ++i)
segments[i].clear;
}
因为没有全局的锁,在清除完一个 segment 之后,正在清理下一个
segment 的时候,已经清理的 segment 可能又被加入了数据,因此 clear
返回的时候,ConcurrentHashMap 中是可能存在数据的。因此,clear 方
法是弱一致的。
ConcurrentHashMap 中的迭代器
在遍历过程中,如果已经遍历的数组上的内容变化了,迭代器不会抛出
ConcurrentModificationException 异常。如果未遍历的数组上的内容发生了
变化,则有可能反映到迭代过程中。这就是 ConcurrentHashMap 迭代器弱一
致的表现。
在这种迭代方式中,当 iterator 被创建后,集合再发生改变就不再是抛出
ConcurrentModificationException,取而代之的是在改变时 new 新的数据
从而不影响原有的数据,iterator 完成后再将头指针替换为新的数据,这样
iterator 线程可以使用原来老的数据,而写线程也可以并发的完成改变,更重
要的,这保证了多个线程并发执行的连续性和扩展性,是性能提升的关键。
总结,ConcurrentHashMap 的弱一致性主要是为了提升效率,是一致性
与效率之间的一种权衡。要成为强一致性,就得到处使用锁,甚至是全局锁,
这就与 Hashtable 和同步的 HashMap 一样了。
CopyOnWrite 容器即写时复制的容器,也就是当我们往一个容器添加元
素的时候,不直接往当前容器添加,而是先将当前容器进行 Copy,复制出一个
新的容器,然后新的容器里添加元素,添加完元素之后,再将原容器的引用指
向新的容器(改变引用的指向)。这样做的好处是我们可以对 CopyOnWrite
容器进行并发的读,而不需要加锁,因为当前容器不会添加任何元素。所以
CopyOnWrite 容器也是一种读写分离的思想,读和写在不同的容器上进行,
注意,写的时候需要加锁。
1.以下代码是向 CopyOnWriteArrayList 中 add 方法的实现,可以发现
在添加的时候是需要加锁的,否则多线程写的时候会 Copy 出 N 个副本出来。
public boolean add(E e) {
final ReentrantLock lock = this.lock;//加的是 lock 锁
lock.lock();
try {
Object[] elements = getArray();
int len = elements.length;
Object[] newElements = Arrays.copyOf(elements, len + 1);
newElements[len] = e;
setArray(newElements);// 将原容器的引用指向新的容器;
return true;
} finally {
lock.unlock();
}
}
在 CopyOnWriteArrayList 里处理写操作(包括 add、remove、set 等)是先将原始的
数据通过 Arrays.copyof()来生成一份新的数组,然后在新的数据对象上进行写,写完后
再将原来的引用指向到当前这个数据对象,这样保证了每次写都是在新的对象上。然后读
的时候就是在引用的当前对象上进行读(包括 get,iterator 等),不存在加锁和阻塞。
CopyOnWriteArrayList 中写操作需要大面积复制数组,所以性能肯定很差,但是读操
作因为操作的对象和写操作不是同一个对象,读之间也不需要加锁,读和写之间的同步处
理只是在写完后通过一个简单的“=”将引用指向新的数组对象上来,这个几乎不需要时间,
这样读操作就很快很安全,适合在多线程里使用。
2. 读的时候不需要加锁, 如 果 读 的 时 候 有 线 程 正 在 向
CopyOnWriteArrayList 添加数据,读还是会读到旧的数据(在原容器中进行
读)。
public E get(int index) {
return get(getArray(), index);
}
CopyOnWriteArrayList 在读上效率很高,由于,写的时候每次都要将源
数组复制到一个新组数中,所以写的效率不高。
CopyOnWrite 容器有很多优点,但是同时也存在两个问题,即内存占用
问题和数据一致性问题。
因为 CopyOnWrite 的写时复制机制,所以在进行写操
作的时候,内存里会同时驻扎两个对象的内存,旧的对象和新写入的对象。针
对内存占用问题,可以
1)通过压缩容器中的元素的方法来减少大对象的内存消耗,比如,如果元
素全是 10 进制的数字,可以考虑把它压缩成 36 进制或 64 进制。
2) 不使用 CopyOnWrite 容器, 而使用其他的并发容器, 如
ConcurrentHashMap。
CopyOnWrite 容器只能保证数据的最终一致性,不
能保证数据的实时一致性。所以如果你希望写入的数据,马上能读到,请不要
使用 CopyOnWrite 容器!!
栈是一块和线程紧密相关的内存区域。每个线程都有自己的栈内存,用于 存储本地变量,方法参数和栈调用,一个线程中存储的变量对其它线程是不可 见的。而堆是所有线程共享的一片公用内存区域。对象都在堆里创建,为了提 升效率线程会从堆中弄一个缓存到自己的栈,如果多个线程使用该变量就可能 引发问题,这时 volatile 变量就可以发挥作用了,它要求线程从主存中读取变 量的值。
死锁:是指两个或两个以上的进程在执行过程中,因争夺资源而造成的一 种互相等待的现象,若无外力作用,它们都将无法推进下去,此时称系统处于 死锁状态或系统产生了死锁。 饥饿:考虑一台打印机分配的例子,当有多个进程需要打印文件时,系统 按照短文件优先的策略排序,该策略具有平均等待时间短的优点,似乎非常合 理,但当短文件打印任务源源不断时,长文件的打印任务将被无限期地推迟, 导致饥饿以至饿死。 活锁:与饥饿相关的另外一个概念称为活锁,在忙式等待条件下发生的饥 饿,称为活锁。 不进入等待状态的等待称为忙式等待。另一种等待方式是阻塞式等待,进 程得不到共享资源时将进入阻塞状态,让出 CPU 给其他进程使用。忙等待和阻 塞式等待的相同之处在于进程都不具备继续向前推进的条件,不同之处在于处 于忙等待的进程不主动放弃 CPU,尽管 CPU 可能被剥夺,因而是低效的;而 处于阻塞状态的进程主动放弃 CPU ,因而是高效的。 活锁的例子:如果事务 T1 封锁了数据 R,事务 T2 又请求封锁 R,于是 T2 等待。T3 也请求封锁 R,当 T1 释放了 R 上的封锁后,系统首先批准了 T3 的 请求,T2 仍然等待。然后 T4 又请求封锁 R,当 T3 释放了 R 上的封锁之后, 系统又批准了 T4 的请求......T2 可能永远等待(在整个过程中,事务 T2 在不 断的重复尝试获取锁 R)。 活锁的时候,进程是不会阻塞的,这会导致耗尽 CPU 资源,这是与死锁最 明显的区别。 活锁指的是任务或者执行者没有被阻塞,由于某些条件没有满足,导致一 直重复尝试,失败,尝试,失败。 活锁和死锁的区别在于,处于活锁的实体是 在不断的改变状态,所谓的“活”, 而处于死锁的实体表现为等待;活锁有一定 几率解开,而死锁是无法解开的。 避免活锁的简单方法是采用先来先服务的策略。当多个事务请求封锁同一 数据对象时,封锁子系统按请求封锁的先后次序对事务排队,数据对象上的锁 一旦释放就批准申请队列中第一个事务获得锁。