【美图-选择题考点整理-第二篇】

这篇文章是在总结ConcurrentHashMap时,发现了volatile,之前就想总结的。于是,就先从volatile说起吧,再浅谈ConcurrentHashMap。

一、细说volatile:(参考 volatile关键字: http://blog.csdn.net/u010887744/article/details/50275517)
    1. 内存模型的相关概念

    当程序在运行过程中,会将运算需要的数据从主存复制一份到CPU的高速缓存当中,那么CPU进行计算时就可以直接从它的高速缓存读取数据和向其中写入数据,当运算结束之后,再将高速缓存中的数据刷新到主存当中。但是,如果一个变量在多个CPU中都存在缓存(一般在多线程编程时才会出现),那么就可能存在缓存不一致的问题。

    为了解决缓存不一致性问题,通常来说有以下2种解决方法:

1)通过在总线加LOCK#锁的方式

2)通过缓存一致性协议(最出名的就是Intel 的MESI协议,MESI协议保证了每个缓存中使用的共享变量的副本是一致的。它核心的思想是:当CPU写数据时,如果发现操作的变量是共享变量,即在其他CPU中也存在该变量的副本,会发出信号通知其他CPU将该变量的缓存行置为无效状态,因此当其他CPU需要读取这个变量时,发现自己缓存中缓存该变量的缓存行是无效的,那么它就会从内存重新读取)

    2. 并发编程中的三个概念

    在并发编程中,通常会遇到以下三个问题:原子性问题,可见性问题,有序性问题。我们先看具体看一下这三个概念:

①.原子性

原子性:即一个操作或者多个操作 要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行。一个很经典的例子就是银行账户转账问题。

②.可见性

可见性是指当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值。

//线程1执行的代码

int i = 0;

i = 10;  

//线程2执行的代码

j = i;

    假若执行线程1的是CPU1,执行线程2的是CPU2。由上面的分析可知,当线程1执行 i =10这句时,会先把i的初始值加载到CPU1的高速缓存中,然后赋值为10,那么在CPU1的高速缓存当中i的值变为10了,却没有立即写入到主存当中。此时线程2执行 j = i,它会先去主存读取i的值并加载到CPU2的缓存当中,注意此时内存当中i的值还是0,那么就会使得j的值为0,而不是10.这就是可见性问题,线程1对变量i修改了之后,线程2没有立即看到线程1修改的值。

③.有序性:即程序执行的顺序按照代码的先后顺序执行。

int i = 0;              

boolean flag = false;

i = 1;  //语句1  

flag = true;  //语句2

    上面代码定义了一个int型变量,定义了一个boolean类型变量,然后分别对两个变量进行赋值操作。从代码顺序上看,语句1是在语句2前面的,那么JVM在真正执行这段代码的时候会保证语句1一定会在语句2前面执行吗?不一定,为什么呢?这里可能会发生指令重排序(Instruction Reorder)。

    但!处理器在进行重排序时是会考虑指令之间的数据依赖性,如果一个指令Instruction 2必须用到Instruction 1的结果,那么处理器会保证Instruction 1会在Instruction 2之前执行。

    综上,要想并发程序正确地执行,必须要保证原子性、可见性以及有序性。只要有一个没有被保证,就有可能会导致程序运行不正确

    3. Java内存模型

    Java虚拟机规范中试图定义一种Java内存模型(Java Memory Model,JMM)来屏蔽各个硬件平台和操作系统的内存访问差异,以实现让Java程序在各种平台下都能达到一致的内存访问效果。

    那么Java内存模型规定了哪些东西呢,它定义了程序中变量的访问规则,往大一点说是定义了程序执行的次序。注意,为了获得较好的执行性能,Java内存模型并没有限制执行引擎使用处理器的寄存器或者高速缓存来提升指令执行速度,也没有限制编译器对指令进行重排序。也就是说,在java内存模型中,也会存在缓存一致性问题和指令重排序的问题

    那么Java语言 本身对 原子性、可见性以及有序性提供了哪些保证呢?

①.原子性

在Java中,对基本数据类型的变量的读取和赋值操作是原子性操作,即这些操作是不可被中断的,要么执行,要么不执行。

上面一句话虽然看起来简单,但是理解起来并不是那么容易。看下面一个例子:

分析以下哪些操作是原子性操作:

x = 10;         //语句1

y = x;         //语句2

x++;           //语句3

x = x + 1;     //语句4

    答案是:只有语句1是原子性操作,其他三个语句都不是原子性操作。语句1是直接将数值10赋值给x,也就是说线程执行这个语句的会直接将数值10写入到工作内存中。语句2实际上包含2个操作,它先要去读取x的值,再将x的值写入工作内存,虽然读取x的值以及 将x的值写入工作内存 这2个操作都是原子性操作,但是合起来就不是原子性操作了。同样的,x++和 x = x+1包括3个操作:读取x的值,进行加1操作,写入新的值。

    所以上面4个语句只有语句1的操作具备原子性。也就是说,只有简单的读取、赋值(而且必须是将数字赋值给某个变量,变量之间的相互赋值不是原子操作)才是原子操作Java内存模型只保证了基本读取赋值是原子性操作,如果要实现更大范围操作的原子性,可以通过synchronizedLock来实现。由于synchronized和Lock能够保证任一时刻只有一个线程执行该代码块,那么自然就不存在原子性问题了,从而保证了原子性。

②.可见性

    对于可见性,Java提供了volatile关键字来保证可见性。当一个共享变量被volatile修饰时,它会保证修改的值会立即被更新到主存,当有其他线程需要读取时,它会去内存中读取新值。而普通的共享变量不能保证可见性,因为普通共享变量被修改之后,什么时候被写入主存是不确定的,当其他线程去读取时,此时内存中可能还是原来的旧值,因此无法保证可见性。

    另外,通过synchronized和Lock也能够保证可见性,synchronized和Lock能保证同一时刻只有一个线程获取锁然后执行同步代码,并且在释放锁之前会将对变量的修改刷新到主存当中。因此可以保证可见性。

③.有序性

    在Java内存模型中,允许编译器和处理器对指令进行重排序,但是重排序过程不会影响到单线程程序的执行,却会影响到多线程并发执行的正确性。在Java里面,可以通过volatile关键字来保证一定的“有序性”。另外可以通过synchronized和Lock来保证有序性,很显然,synchronized和Lock保证每个时刻是有一个线程执行同步代码,相当于是让线程顺序执行同步代码,自然就保证了有序性。

    另外,Java内存模型具备一些先天的“有序性”即不需要通过任何手段就能够得到保证的有序性,这个通常也称为 happens-before 原则。如果两个操作的执行次序无法从happens-before原则推导出来,那么它们就不能保证它们的有序性,虚拟机可以随意地对它们进行重排序。

    下面就来具体介绍下happens-before原则(先行发生原则):

程序次序规则:一个线程内,按照代码顺序,书写在前面的操作先行发生于书写在后面的操作

锁定规则:一个unLock操作先行发生于后面对同一个锁的lock操作

volatile变量规则:对一个变量的写操作先行发生于后面对这个变量的读操作

传递规则:如果操作A先行发生于操作B,而操作B又先行发生于操作C,则可以得出操作A先行发生于操作C

线程启动规则:Thread对象的start()方法先行发生于此线程的每个一个动作

线程中断规则:对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生

线程终结规则:线程中所有的操作都先行发生于线程的终止检测,我们可以通过Thread.join()方法结束、Thread.isAlive()的返回值手段检测到线程已经终止执行

对象终结规则:一个对象的初始化完成先行发生于他的finalize()方法的开始

    这8条原则摘自《深入理解Java虚拟机》。这8条规则中,前4条规则是比较重要的,后4条规则都是显而易见的。

    下面解释一下前4条规则:

    第一条规则 对于程序次序规则来说,一段程序代码的执行在单个线程中看起来是有序的。注意,虽然这条规则中提到“书写在前面的操作先行发生于书写在后面的操作”,这个应该是程序看起来执行的顺序是按照代码顺序执行的,因为虚拟机可能会对程序代码进行指令重排序。虽然进行重排序,但是最终执行的结果是与程序顺序执行的结果一致的,它只会对不存在数据依赖性的指令进行重排序。因此,在单个线程中,程序执行看起来是有序执行的,这一点要注意理解。事实上,这个规则是用来保证程序在单线程中执行结果的正确性,但无法保证程序在多线程中执行的正确性。

    第二条规则 无论在单线程中还是多线程中,同一个锁如果出于被锁定的状态,那么必须先对锁进行了释放操作,后面才能继续进行lock操作。

    第三条规则 如果一个线程先去写一个变量,然后一个线程去进行读取,那么写入操作肯定会先行发生于读操作。

    第四条规则 实际上就是体现happens-before原则具备传递性。

    4. 深入剖析volatile关键字

①.volatile关键字的两层语义,一旦一个共享变量(类的成员变量、类的静态成员变量)被volatile修饰之后,那么就具备了两层语义:

1)保证了不同线程对这个变量进行操作时的可见性,即一个线程修改了某个变量的值,这新值对其他线程来说是立即可见的。

2)禁止进行指令重排序。

    对于原子性,volatile关键字能保证可见性没有错,但可见性只能保证每次读取的是最新的值,但是volatile没办法保证对变量的操作的原子性。

    再次强调,自增操作是不具备原子性的,它包括读取变量的原始值、进行加1操作、写入工作内存

    注意:前面不是保证一个变量在修改volatile变量时,会让缓存行无效吗?然后其他线程去读就会读到新的值,对,这个没错。这个就是上面的happens-before规则中的volatile变量规则,但是要注意,线程1对变量进行读取操作之后,被阻塞了的话,并没有对inc值进行修改。然后虽然volatile能保证线程2对变量inc的值读取是从内存中读取的,但是线程1没有进行修改,所以线程2根本就不会看到修改的值。所以volatile无法保证原子性。

    补充:在java 1.5的java.util.concurrent.atomic包下提供了一些原子操作类,即对基本数据类型的 自增(加1操作),自减(减1操作)、以及加法操作(加一个数),减法操作(减一个数)进行了封装,保证这些操作是原子性操作。atomic是利用CAS来实现原子性操作的(Compare And Swap),CAS实际上是利用处理器提供的CMPXCHG指令实现的,而处理器执行CMPXCHG指令是一个原子性操作。

    ☆ volatile的原理和实现机制 

下面这段话摘自《深入理解Java虚拟机》:

“观察加入volatile关键字和没有加入volatile关键字时所生成的汇编代码发现,加入volatile关键字时,会多出一个lock前缀指令”

lock前缀指令实际上相当于一个内存屏障(也成内存栅栏),内存屏障会提供3个功能:

1)它确保指令重排序时不会把其后面的指令排到内存屏障之前的位置,也不会把前面的指令排到内存屏障的后面;即在执行到内存屏障这句指令时,在它前面的操作已经全部完成;

2)它会强制将对缓存的修改操作立即写入主存;

3)如果是写操作,它会导致其他CPU中对应的缓存行无效。

    5. 使用volatile关键字的场景

①.状态标记量

volatile boolean flag = false;

 

while(!flag){

doSomething();

}

 

public void setFlag() {

flag = true;

}

 

volatile boolean inited = false;

//线程1:

context = loadContext();  

inited = true;            

 

//线程2:

while(!inited ){

sleep()

}

doSomethingwithconfig(context);

 

②.double check(关于双重检测,以前的双重检查锁定的问题是:并不能保证它会在单处理器或多处理器计算机上顺利运行。双重检查锁定失败的问题并不归咎于 JVM 中的实现 bug,而是归咎于 Java 平台内存模型。内存模型允许所谓的“无序写入”,这也是这些习语失败的一个主要原因。意外收获,详细见:Java单例模式中双重检查锁的问题 http://blog.csdn.net/chenchaofuck1/article/details/51702129)

class Singleton{

private volatile static Singleton instance = null;

 

private Singleton() {}

 

public static Singleton getInstance() {

if(instance==null) {

synchronized (Singleton.class) {

if(instance==null) instance = new Singleton();

}

}

return instance;

}

}

二、初识ConcurrentHashMap(参考: ConcurrentHashMap源码分析_JDK1.8版本 https://segmentfault.com/a/1190000009001468)

JDK1.6版本:

①.ConcurrentHashMap结构:ConcurrentHashMap将数据分成一段一段存储,给每一段数据配一把锁,当一个线程获得锁互斥访问一个段数据时,其他段的数据也可被其他线程访问;

每个Segment拥有一把可重入锁,因此ConcurrentHashMap的分段锁数目即为Segment数组长度。

ConcurrentHashMap结构:每一个segment都是一个HashEntry<K,V>[] table,table中的每一个元素本质上都是一个HashEntry的单向队列(单向链表实现)。

②.锁分离实现:当一个线程访问Node/键值对数据时,必须获得与它对应的segment锁,其他线程可以访问其他Segment中的数据(锁分离);

补充:乐观锁与悲观锁

悲观锁 比如synchronized锁,为确保其他线程不会干扰当前线程工作,因此挂起其他需要锁的线程,等待持有锁的线程释放;

乐观锁 总是假设没有冲突发生去做操作,如果检测到冲突就失败重试,直到成功为止;

③.无锁算法:CAS算法

CAS(Compare And Swap):CAS算法包含三个参数CAS(V, E, N),判断预期值E和内存旧值是否相同(Compare),如果相等用新值N覆盖旧值V(Swap),否则失败;当多个线程尝试使用CAS同时更新同一个变量时,只有其中一个线程能更新变量的值,其他线程失败(失败线程不会被阻塞,而是被告知“失败”,可以继续尝试);

优点:CAS在硬件层面可以被编译为机器指令执行,因此性能高于基于锁占有方式实现线程安全


JDK1.8版本:

①.ConcurrentHashMap结构:JDK 1.8取消类segments字段,直接用table数组存储键值对,JDK1.6中每个bucket中键值对组织方式是单向链表,查找复杂度是O(n),JDK1.8中当链表长度超过TREEIFY_THRESHOLD时,链表转换为红黑树,查询复杂度可以降低到O(log n),改进性能;

②.锁分离实现:JDK1.8中,一个线程每次对一个桶(链表 or 红黑树)进行加锁,其他线程仍然可以访问其他桶;

③.线程安全

ConcurrentHashMap底层数据结构与HashMap相同,仍然采用table数组+链表+红黑树结构;

一个线程进行put/remove操作时,对桶(链表 or 红黑树)加上synchronized独占锁;

ConcurrentHashMap采用CAS算法保证线程安全;

④.ConcurrentHashMap基本数据结构

transient volatile Node<K,V>[] table:键值对桶数组

private transient volatile Node<K,V>[] nextTable: rehash扩容时用到的新键值对数组

private transient volatile long baseCount 记录当前键值对总数,通过CAS更新,对所有线程可见

private transient volatile int sizeCtl

sizeCtl表示键值对总数阈值,通过CAS更新, 对所有线程可见

当sizeCtl < 0时,表示多个线程在等待扩容;

当sizeCtl = 0时,默认值;

当sizeCtl > 0时,表示扩容的阈值;

private transient volatile int cellBusy:自旋锁;

private transient volatile CounterCell[] counterCells: counter cell表,长度总为2的幂次;

static class Segment<K,V>:在JDK1.8中,Segment类仅仅在序列化和反序列化时发挥作用;

⑤.ConcurrentHashMap重要方法分析

1) 构造函数

public ConcurrentHashMap(int initialCapacity,float loadFactor, int concurrencyLevel) {

if (!(loadFactor > 0.0f) || initialCapacity < 0 || concurrencyLevel <= 0)

throw new IllegalArgumentException();

if (initialCapacity < concurrencyLevel)   // Use at least as many bins

initialCapacity = concurrencyLevel;   // as estimated threads

long size = (long)(1.0 + (long)initialCapacity / loadFactor);

int cap = (size >= (long)MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : tableSizeFor((int)size);

this.sizeCtl = cap;

}

其中:

 --该构造器会根据输入的initialCapacity确定一个 >= initialCapacity的最小2的次幂

 --concurrentLevel:在JDK1.8之前本质是ConcurrentHashMap分段锁总数,表示同时更新ConcurrentHashMap且不产生锁竞争的最大线程数;在JDK1.8中,仅在构造器中确保初始容量>=concurrentLevel,为兼容旧版本而保留;

2) 添加/更新键值对:putVal,有旧值返回旧值,否则返回null

final V putVal(K key, V value, boolean onlyIfAbsent) {

if (key == null || value == null) throw new NullPointerException();//和HashMap有区别,hashmap可以设置一个null-key的键值对

int hash = spread(key.hashCode());

int binCount = 0;

// 不断CAS探测,如果其他线程正在修改tab,CAS尝试失败,直到成功为止

for (Node<K,V>[] tab = table;;) {

Node<K,V> f; int n, i, fh;

// 空表,对tab进行初始化

if (tab == null || (n = tab.length) == 0)

tab = initTable();

/**

* CAS探测空桶

* 计算key所在bucket表中数组索引: i = (n - 1) & hash)

*/

else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {

// CAS添加新键值对

if (casTabAt(tab, i, null,new Node<K,V>(hash, key, value, null)))

break;// no lock when adding to empty bin

}

// 检测到tab[i]桶正在进行rehash,

else if ((fh = f.hash) == MOVED)

tab = helpTransfer(tab, f);

else {

V oldVal = null;

/ 对桶的首元素上锁独占

synchronized (f) {

if (tabAt(tab, i) == f) {

// 桶中键值对组织形式是链表

if (fh >= 0) {

binCount = 1;

for (Node<K,V> e = f;; ++binCount) {

K ek;

if (e.hash == hash &&((ek = e.key) == key ||(ek != null && key.equals(ek)))) {

oldVal = e.val;

// 查找到对应键值对,更新值

if (!onlyIfAbsent)

e.val = value;

break;

}

// 桶中没有对应键值对,插入到链表尾部

Node<K,V> pred = e;

if ((e = e.next) == null) {

pred.next = new Node<K,V>(hash, key,value, null);

break;

}

}

}

// 桶中键值对组织形式是红黑树

else if (f instanceof TreeBin) {

Node<K,V> p;

binCount = 2;

if ((p = ((TreeBin<K,V>)f).putTreeVal(hash, key,value)) != null) {

oldVal = p.val;

if (!onlyIfAbsent)

p.val = value;

}

}

}

}

// 检查桶中键值对总数

if (binCount != 0) {

if (binCount >= TREEIFY_THRESHOLD)

// 链表转换为红黑树

treeifyBin(tab, i);

if (oldVal != null)

return oldVal;

break;

}

}

}

// 更新baseCount

addCount(1L, binCount);

return null;

}

-- 其中,synchronized (f) {}操作通过对桶的首元素 = 链表表头 Or 红黑树根节点加锁,从而实现对整个桶进行加锁,有锁分离思想的体现;

小结:

先判断key、value是否有空值情况,如果有,抛出空指针异常,没有就计算key的hash值

volatile配合循环形成CAS不断探测,如果其他线程正在修改tab,CAS尝试失败,直到成功为止

空表,对tab进行初始化

CAS探测空桶,计算key所在bucket表中数组索引: i = (n - 1) & hash),再添加新键值对

检测到tab[i]桶正在进行rehash,helpTransfer(tab, f)

对桶的首元素上锁独占

桶中键值对组织形式是链表,查找到对应键值对,更新值;桶中没有对应键值对,插入到链表尾部

桶中键值对组织形式是红黑树,旧的value非空时,取出旧值,放入新值。

检查桶中键值对总数

更新baseCount

3) 获取键值对:get

public V get(Object key) {

    Node<K,V>[] tab; Node<K,V> e, p; int n, eh; K ek;

    int h = spread(key.hashCode());

    if ((tab = table) != null && (n = tab.length) > 0 && (e = tabAt(tab, (n - 1) & h)) != null) {

        if ((eh = e.hash) == h) {

            if ((ek = e.key) == key || (ek != null && key.equals(ek)))

                return e.val;

        }

        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;

}

小结:

计算hash,当table有效时,返回hash值对应的val值,get方法通过CAS保证键值对的原子性,当tab[i]被锁住,CAS失败并不断重试,保证get不会出错;

4) table原子操作方法

获取tab[i]:tabAt

static final <K,V> Node<K,V> tabAt(Node<K,V>[] tab, int i) {

    return (Node<K,V>)U.getObjectVolatile(tab, ((long)i << ASHIFT) + ABASE);

}

    tabAt方法原子读取table[i];调用Unsafe对象的getObjectVolatile方法获取tab[i],由于对volatile写操作happen-before于volatile读操作,因此其他线程对table的修改均对get读取可见;

((long)i << ASHIFT) + ABASE)计算i元素的地址

5) CAS算法更新键值对:casTabAt

static final <K,V> boolean casTabAt(Node<K,V>[] tab, int i,

                                    Node<K,V> c, Node<K,V> v) {

    return U.compareAndSwapObject(tab, ((long)i << ASHIFT) + ABASE, c, v);

}

casTabAt通过compareAndSwapObject方法比较tabp[i]和v是否相等,相等就用c更新tab[i];

6) 更新键值对:setTabAt

static final <K,V> void setTabAt(Node<K,V>[] tab, int i, Node<K,V> v) {

    U.putObjectVolatile(tab, ((long)i << ASHIFT) + ABASE, v);

}

仅在synchronized同步块中被调用,更新键值对;


猜你喜欢

转载自blog.csdn.net/Juwenzhe_HEBUT/article/details/77882752