ConcurrentHashmap的读操作为什么不加synchronized
- 去看ConcurrentHashmap的putVal源码会发现在putVal内部使用了Synchronized关键词保证了线程安全
- 但是看get源码的时候发现根本没有用synchronized关键字,那么get方法是如何保证线程安全的呢?
- 源码如下
public V get(Object key) {
Node<K,V>[] tab; Node<K,V> e, p; int n, eh; K ek;
//计算hash
int h = spread(key.hashCode());
if ((tab = table) != null && (n = tab.length) > 0 &&
(e = tabAt(tab, (n - 1) & h)) != null) {//读取首节点的Node元素
if ((eh = e.hash) == h) {//如果该节点是首节点就返回
if ((ek = e.key) == key || (ek != null && key.equals(ek)))
return e.val;
}
//hash值为负值表示正在扩容,这个时候查得是ForwardingNode的find方法来定位到nextTable
//eh=-1,表示该节点是一个ForwaringNode,正在迁移,此时调用ForwardingNode的find方法去nextTable里找
//eh=-2,说明该节点是一个TreeBin,此时调用TreeBin的find方法遍历红黑树,由于红黑树有可能正在旋转变色,所以find里会有读写锁
//eh>=0,说明该节点下卦的是一个链表,直接遍历该链表即可
else if (eh < 0)
return (p = e.find(h, key)) != null ? p.val : null;
while ((e = e.next) != null) {
if (e.hash == h &&
((ek = e.key) == key || (ek != null && key.equals(ek))))
return e.val;
}
}
return null;
}
- 对比了hashmap和concurrenthashmap,发现concurrenthashmap的table和Node中的val和next均加了volatile关键字,是因为volatile关键字吗?
volatile
-
volatile被称为轻量级的synchronized,运行时开销比synchronized更小,在多线并发编程中发挥着同步共享变量、禁止处理器重排序的重要作用。
-
volatile关键字可以用来保证可见性、有序性,但不保证原子性。
-
Java通过几种原子操作完成工作区内存和主存的交互
- lock:作用于主存,把变量标识为线程独占状态
- unlock:作用于主存,解除变量的独占状态
- read:作用于主存,把一个变量的值通过主存传输到线程的工作区内存中
- load:作用于工作区内存,把read操作传过来的变量值存储到工作区内存的变量副本中
- use:作用于工作内存,把工作区内存的变量副本传送给执行引擎
- assign:作用于工作区内存,把从执行引擎传过来的值赋值给工作区内存的变量副本
- store:作用于工作区内存,把工作区内存的变量副本传给主存
- write:作用于主存:把store操作传过来的值赋值给主存变量
-
这八个操作都是原子性的,但是几个操作连在一起就不是原子性了。
volatile原理
可见性
- 什么是可见性,看下列代码
//线程1
boolean stop = false;
while (!stop) {
do();
}
//线程2
stop = true;
- 线程1执行后会进入到一个死循环中,当线程2执行后,线程1的死循环就一定会马上结束吗?答案是不一定,因为线程2执行完stop = true后,并不会马上将变量stop的值true写回主存中,也就是上图中的assign执行完成之后,store和write并不会随着执行,线程1没有立即将修改后的变量的值更新到主存中,即使线程2及时将变量stop的值写回主存中了,线程1也没有了解到变量stop的值已被修改而去主存中重新获取,也就是线程1的load、read操作并不会马上执行造成线程1的工作区内存中的变量副本不是最新的。这两个原因造成了线程1的死循环也就不会马上结束。
那么如何避免上诉的问题呢?我们可以使用volatile关键字修饰变量stop,如下
//线程1
volatile boolean stop = false;
while (!stop) {
do();
}
//线程2
stop = true;
-
这样线程1每次读取变量stop的时候都会先去主存中获取变量stop最新的值,线程2每次修改变量stop的值之后都会马上将变量的值写回主存中,这样也就不会出现上述的问题了。
-
那么关键字volatie是如何做到的呢?volatie规定了上述8个操作的规则
- 只有当线程对变量执行的前一个操作是load时,线程才能对变量执行use操作;只有线程的后一个操作是load时,线程才能对变量执行read操作。即规定了use、load、read三个操作之间的约束关系,规定这三个操作必须连续的出现,保证了线程每次读取变量的值都必须去主存获取最新的值
- 只有当线程对变量执行的前一个操作是assign时,线程才能对变量执行store操作;只有线程的后一个操作是write时,线程才能对变量执行store操作,即规定了assign、store、write三个操作之间的约束关系,规定了这三个操作必须连续的出现,保证线程每次修改变量后都必须将变量的值写回主存
有序性
- 有序性即程序执行的顺序按照代码的先后顺序执行,JMM允许编译器和处理器对指令进行重排序,但是规定了as-if-serial语义,即保证单线程情况下不管怎么重排序,程序的结果不能改变。
- 在多线程情况下重排序就很可能出现问题
double pi = 3.14;
double r = 0;
double s = 0;
boolean start = false;
//线程1
r = 10; //A
start = true; //B
//线程2
if (start) { //C
s = pi * r * r; //D
}
- 线程1和线程2同时执行,线程1的A和B的执行顺序可能是A->B或者B->A(因为A和B之间没有依赖关系,可以指令重排序)。如果线程1按照A->B的顺序执行,那么线程2执行后的结果s就是我们想要的正确结果,如果线程1按照B->A的顺序执行,那么线程2执行后的结果s可能就不是我们想要的结果了,因为线程1将变量stop的值修改为true后,线程2马上获取到stop为true然后执行C语句,然后执行D语句即s = 3.14 * 0 * 0,然后线程1再执行B语句,那么结果就是有问题了。
- 为了解决这个问题,我们可以在变量start上加上volatile
double pi = 3.14;
double r = 0;
double s = 0;
volatile boolean start = false;
//线程1
r = 10; //A
start = true; //B
//线程2
if (start) { //C
s = pi * r * r; //D
}
-
这样线程1的执行顺序就只能是A->B了,因为关键字发挥了禁止处理器指令重排序的作用,所以线程2的执行结果就不会有问题了。
-
那么volatile是如何实现禁止处理器重排序的呢?
-
编译器会在编译生成字节码的时候,在加有volatile关键字的变量的指令进行插入内存屏障来禁止特定类型的处理器重排序。
-
看下内存屏障以及发挥的作用
- StoreStore屏障:禁止屏障上面变量的写和下面所有进行写的变量进行处理器重排序
- StoreLoad屏障:禁止屏障上面变量的写和下面所有进行读的变量进行处理器重排序
- LoadLoad屏障:禁止屏障上面变量的读和下面所有进行读的变量进行处理器重排序
- LoadStore屏障:禁止屏障上面变量的读和下面所有进行写的变量进行处理器重排序
-
volatile是怎么插入屏障的呢?
- 在每个volatile变量的写前面插入一个StoreStore屏障
- 在每个volatile变量的写后面插入一个StoreLoad屏障
- 在每个volatile变量的读后面插入一个LoadLoad屏障
- 在每个volatile变量的读后面插入一个LoadStore屏障
-
注意,写操作是在volatile变量前后插入一个内存屏障,而读操作是在后面插入两个内存屏障
-
volatile变量通过插入内存屏障禁止了处理器重排序,从而解决了多线程环境下处理器重排序的问题。
为什么volatile不保证原子性?
- 跑一段测试代码
public class VolatileTest {
private static final ThreadPoolExecutor EXECUTOR_SERVICE = new ThreadPoolExecutor(100,120,60, TimeUnit.SECONDS,
new ArrayBlockingQueue<>(1000),
new ThreadFactoryBuilder().setNameFormat("mycyclicbarrier-pool-%d").build(),
new ThreadPoolExecutor.CallerRunsPolicy());
volatile int num = 0;
public void inc(){
num++;
}
public static void main(String[] args) {
final VolatileTest test = new VolatileTest();
for(int i=0;i<5;i++){
new Thread(){
@Override
public void run() {
for(int j=0;j<10;j++)
test.inc();
};
}.start();
}
while(Thread.activeCount()>1)
Thread.yield();
System.out.println(test.num);//一般情况下小于50
}
}
-
因为线程每次将值协会主存的时候并不能保证主存中的值也没有被其他的线程修改过。
-
volatile关键字保证了变量读操作的原子性和写操作的原子性,而变量的自增过程需要对变量进行读和写两个过程,而这两个过程连在一起就不是原子性操作了。
-
解决方法:
- 给inc方法加锁
- 利用CAS对volatile进行操作,比加锁的方法消耗要少
-
volatile和CAS的区别
- volatile只能保证共享变量的读和写单个操作的原子性,而CAS保证了共享变量的读和写两个操作一起的原子性
- volatile的实现基于JMM,而CAS的实现基于硬件
原因分析
加了volatile关键字的table?
- volatile可以修饰数组,但是它的意思是保证该数组地址的可见性和有序性,不保证数据内部数据是否发生变更
加了volatile关键字的val和next变量
-
get操作可以无锁是因为Node的元素val和指针next都是用volatile修饰的,在多线程环境下线程A修改节点的val或者新增节点的时候是对线程B可见的。
-
那么为什么要在table前面加上volatile呢?
- 为了使Node数组在扩容的时候对其他线程具有可见性
-
ConcurrentHashMap的get操作全程不需要加锁,这也是它比其他并发集合比如hashtable、用Collections.synchronizedMap()包装的hashmap;安全效率高的原因之一。