java中ConcurrentHashMap的读操作为什么不需要加锁

前言

ConcurrentHashMap是Java 5中支持高并发、高吞吐量的线程安全HashMap实现。

我们知道,ConcurrentHashmap(1.8)这个并发集合框架是线程安全的,当你看到源码的get操作时,会发现get操作全程是没有加任何锁的,这也是这篇博文讨论的问题——为什么它不需要加锁呢?

下面话不多说了,来一起看看详细的介绍吧

ConcurrentHashMap的简介

我想有基础的同学知道在jdk1.7中是采用Segment + HashEntry + ReentrantLock的方式进行实现的,而1.8中放弃了Segment臃肿的设计,取而代之的是采用Node + CAS + Synchronized来保证并发安全进行实现。

  • JDK1.8的实现降低锁的粒度,JDK1.7版本锁的粒度是基于Segment的,包含多个HashEntry,而JDK1.8锁的粒度就是HashEntry(首节点)
  • JDK1.8版本的数据结构变得更加简单,使得操作也更加清晰流畅,因为已经使用synchronized来进行同步,所以不需要分段锁的概念,也就不需要Segment这种数据结构了,由于粒度的降低,实现的复杂度也增加了
  • JDK1.8使用红黑树来优化链表,基于长度很长的链表的遍历是一个很漫长的过程,而红黑树的遍历效率是很快的,代替一定阈值的链表,这样形成一个最佳拍档

get操作源码

  1. 首先计算hash值,定位到该table索引位置,如果是首节点符合就返回
  2. 如果遇到扩容的时候,会调用标志正在扩容节点ForwardingNode的find方法,查找该节点,匹配就返回
  3. 以上都不符合的话,就往下遍历节点,匹配就返回,否则最后就返回null
//会发现源码中没有一处加了锁
public V get(Object key) {
 Node<K,V>[] tab; Node<K,V> e, p; int n, eh; K ek;
 int h = spread(key.hashCode()); //计算hash
 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,说明该节点是一个ForwardingNode,正在迁移,此时调用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) {//既不是首节点也不是ForwardingNode,那就往下遍历
  if (e.hash == h &&
  ((ek = e.key) == key || (ek != null && key.equals(ek))))
  return e.val;
 }
 }
 return null;
}

get没有加锁的话,ConcurrentHashMap是如何保证读到的数据不是脏数据的呢?

volatile登场

对于可见性,Java提供了volatile关键字来保证可见性、有序性。但不保证原子性。
普通的共享变量不能保证可见性,因为普通共享变量被修改之后,什么时候被写入主存是不确定的,当其他线程去读取时,此时内存中可能还是原来的旧值,因此无法保证可见性。

  • volatile关键字对于基本类型的修改可以在随后对多个线程的读保持一致,但是对于引用类型如数组,实体bean,仅仅保证引用的可见性,但并不保证引用内容的可见性。。
  • 禁止进行指令重排序。

  背景:为了提高处理速度,处理器不直接和内存进行通信,而是先将系统内存的数据读到内部缓存(L1,L2或其他)后再进行操作,但操作完不知道何时会写到内存。

  • 如果对声明了volatile的变量进行写操作,JVM就会向处理器发送一条指令,将这个变量所在缓存行的数据写回到系统内存。但是,就算写回到内存,如果其他处理器缓存的值还是旧的,再执行计算操作就会有问题。
  • 在多处理器下,为了保证各个处理器的缓存是一致的,就会实现缓存一致性协议,当某个CPU在写数据时,如果发现操作的变量是共享变量,则会通知其他CPU告知该变量的缓存行是无效的,因此其他CPU在读取该变量时,发现其无效会重新从主存中加载数据。

总结下来:

  • 第一:使用volatile关键字会强制将修改的值立即写入主存;
  • 第二:使用volatile关键字的话,当线程2进行修改时,会导致线程1的工作内存中缓存变量的缓存行无效(反映到硬件层的话,就是CPU的L1或者L2缓存中对应的缓存行无效);
  • 第三:由于线程1的工作内存中缓存变量的缓存行无效,所以线程1再次读取变量的值时会去主存读取。

是加在数组上的volatile吗?

 /**
  * The array of bins. Lazily initialized upon first insertion.
  * Size is always a power of two. Accessed directly by iterators.
  */
 transient volatile Node<K,V>[] table;

我们知道volatile可以修饰数组的,只是意思和它表面上看起来的样子不同。举个栗子,volatile int array[10]是指array的地址是volatile的而不是数组元素的值是volatile的.

用volatile修饰的Node

get操作可以无锁是由于Node的元素val和指针next是用volatile修饰的,在多线程环境下线程A修改因为hash冲突修改结点的val或者新增节点的时候是对线程B可见的。

static class Node<K,V> implements Map.Entry<K,V> {
 final int hash;
 final K key;
 //可以看到这些都用了volatile修饰
 volatile V val;
 volatile Node<K,V> next;

 Node(int hash, K key, V val, Node<K,V> next) {
  this.hash = hash;
  this.key = key;
  this.val = val;
  this.next = next;
 }

 public final K getKey()  { return key; }
 public final V getValue()  { return val; }
 public final int hashCode() { return key.hashCode() ^ val.hashCode(); }
 public final String toString(){ return key + "=" + val; }
 public final V setValue(V value) {
  throw new UnsupportedOperationException();
 }

 public final boolean equals(Object o) {
  Object k, v, u; Map.Entry<?,?> e;
  return ((o instanceof Map.Entry) &&
    (k = (e = (Map.Entry<?,?>)o).getKey()) != null &&
    (v = e.getValue()) != null &&
    (k == key || k.equals(key)) &&
    (v == (u = val) || v.equals(u)));
 }

 /**
  * Virtualized support for map.get(); overridden in subclasses.
  */
 Node<K,V> find(int h, Object k) {
  Node<K,V> e = this;
  if (k != null) {
   do {
    K ek;
    if (e.hash == h &&
     ((ek = e.key) == k || (ek != null && k.equals(ek))))
     return e;
   } while ((e = e.next) != null);
  }
  return null;
 }
}

既然volatile修饰数组对get操作没有效果那加在数组上的volatile的目的是什么呢?

其实就是为了使得Node数组在扩容的时候对其他线程具有可见性而加的volatile

总结

在1.8中ConcurrentHashMap的get操作全程不需要加锁,这也是它比其他并发集合比如hashtable、用Collections.synchronizedMap()包装的hashmap;安全效率高的原因之一。

get操作全程不需要加锁是因为Node的成员val是用volatile修饰的和数组用volatile修饰没有关系。

数组用volatile修饰主要是保证在数组扩容的时候保证可见性。

好了,以上就是这篇文章的全部内容了,希望本文的内容对大家的学习或者工作具有一定的参考学习价值,如果有疑问大家可以留言交流,谢谢大家对我们的支持。

(0)

相关推荐

  • Java锁机制Lock用法示例

    本文实例讲述了Java锁机制Lock用法.分享给大家供大家参考,具体如下: package com.expgiga.JUC; import java.util.concurrent.locks.Lock; import java.util.concurrent.locks.ReentrantLock; /** * 一.用于解决多线程安全问题的方式: * 1.同步代码块 synchronized 隐式锁 * 2.同步方法 synchronized 隐式锁 * 3.同步锁Lock (jdk1.5以后

  • Java线程公平锁和非公平锁的差异讲解

    公平锁,顾名思义,它是公平的,可以保证获取锁的线程按照先来后到的顺序,获取到锁. 非公平锁,顾名思义,各个线程获取到锁的顺序,不一定和它们申请的先后顺序一致,有可能后来的线程,反而先获取到了锁. 在实现上,公平锁在进行lock时,首先会进行tryAcquire()操作.在tryAcquire中,会判断等待队列中是否已经有别的线程在等待了.如果队列中已经有别的线程了,则tryAcquire失败,则将自己加入队列.如果队列中没有别的线程,则进行获取锁的操作. /** * Fair version o

  • Java多线程产生死锁的必要条件

    线程死锁是指由于两个或者多个线程互相持有对方所需要的资源,导致这些线程处于等待状态,无法前往执行.当线程进入对象的synchronized代码块时,便占有了资源,直到它退出该代码块或者调用wait方法,才释放资源,在此期间,其他线程将不能进入该代码块.当线程互相持有对方所需要的资源时,会互相等待对方释放资源,如果线程都不主动释放所占有的资源,将产生死锁. 死锁是操作系统里里面的一个重要的概念,死锁通常发生在并发的场景里. 死锁是多个进程或线程,彼此争抢资源而陷入僵局的一种情况. 在笔者参加的多次

  • Java分布式锁的概念与实现方式详解

    什么是分布式锁?在回答这个问题之前,我们先回答一下什么是锁. 普通的锁,即在单机多线程环境下,当多个线程需要访问同一个变量或代码片段时,被访问的变量或代码片段叫做临界区域,我们需要控制线程一个一个的顺序执行,否则会出现并发问题. 如何控制呢?就是设置一个各个线程都能看的见的标志.然后,每个线程想访问临界区域时,都要先查看标志,如果标志没有被占用,则说明目前没有线程在访问临界区域.如果标志被占用了,则说明目前有线程正在访问临界区域,则当前线程需要等待. 这个标志,就是锁. 在单机多线程的java程

  • Java中锁的实现和内存语义浅析

    1. 概述 锁是Java并发编程中最重要的同步机制.锁除了让临界区互斥执行外,还可以让释放锁的线程获取同一个锁的线程发送消息. 锁在实际使用时只是明白锁限制了并发访问, 但是锁是如何实现并发访问的, 同学们可能不太清楚, 下面这篇文章就来揭开锁的神秘面纱. 2. 锁的内存语义 当线程获取锁时, JMM会把线程对应的本地内存置为无效. 从而使得被监视器保护的临界区的变量必须从主内存中读取. 当线程释放锁时, JMM会把该线程对应的本地内存中的共享变量刷新到主内存中(并不是不释放锁就不刷新到主内存,

  • Java使用Redisson分布式锁实现原理

    1. 基本用法 <dependency> <groupId>org.redisson</groupId> <artifactId>redisson</artifactId> <version>3.8.2</version> </dependency> Config config = new Config(); config.useClusterServers() .setScanInterval(2000) /

  • Java可重入锁的实现原理与应用场景

    可重入锁,从字面来理解,就是可以重复进入的锁. 可重入锁,也叫做递归锁,指的是同一线程外层函数获得锁之后,内层递归函数仍然有获取该锁的代码,但不受影响. 在JAVA环境下ReentrantLock和synchronized都是可重入锁. synchronized是一个可重入锁.在一个类中,如果synchronized方法1调用了synchronized方法2,方法2是可以正常执行的,这说明synchronized是可重入锁.否则,在执行方法2想获取锁的时候,该锁已经在执行方法1时获取了,那么方法

  • Java源码解析之可重入锁ReentrantLock

    本文基于jdk1.8进行分析. ReentrantLock是一个可重入锁,在ConcurrentHashMap中使用了ReentrantLock. 首先看一下源码中对ReentrantLock的介绍.如下图.ReentrantLock是一个可重入的排他锁,它和synchronized的方法和代码有着相同的行为和语义,但有更多的功能.ReentrantLock是被最后一个成功lock锁并且还没有unlock的线程拥有着.如果锁没有被别的线程拥有,那么一个线程调用lock方法,就会成功获取锁并返回.

  • Java锁之可重入锁介绍

    锁作为并发共享数据,保证一致性的工具,在JAVA平台有多种实现(如 synchronized 和 ReentrantLock等等 ) .这些已经写好提供的锁为我们开发提供了便利,但是锁的具体性质以及类型却很少被提及.本系列文章将分析JAVA下常见的锁名称以及特性,为大家答疑解惑. 四.可重入锁: 本文里面讲的是广义上的可重入锁,而不是单指JAVA下的ReentrantLock. 可重入锁,也叫做递归锁,指的是同一线程 外层函数获得锁之后 ,内层递归函数仍然有获取该锁的代码,但不受影响. 在JAV

  • Java并发编程之重入锁与读写锁

    重入锁 重入锁,顾名思义,就是支持重进入的锁,它表示该锁能够支持一个线程对资源的重复加锁.重进入是指任意线程在获取到锁之后能够再次获取该锁而不会被锁阻塞,该特性的实现需要解决以下两个问题. 1.线程再次获取锁.锁需要去识别获取锁的线程是否为当前占据锁的线程,如果是,则再次成功获取. 2.锁的最终释放.线程重复n次获取了锁,随后在第n次释放该锁后,其他线程能够获取到该锁.锁的最终释放要求锁对于获取进行计数自增,计数表示当前锁被重复获取的次数,而锁被释放时,计数自减,当计数等于0时表示锁已经成功释放

随机推荐