• 由HashMap到synchronized的学习


    一.HashMap

    HashMap 是一个用于存储Key-Value 键值对的集合

    扩容机制

    HashMap的初始大小为16(为什么为16?这与哈希函数有关);

    当HashMap中的元素个数超过数组大小(数组总大小length,不是数组中个数size)loadFactor时,就会进行数组扩容,loadFactor的默认值为0.75,这是一个折中的取值。也就是说,默认情况下,数组大小为16,那么当HashMap中元素个数超过160.75=12(这个值就是代码中的threshold值,也叫做临界值)的时候,就把数组的大小扩展为 2*16=32,即扩大一倍,然后重新计算每个元素在数组中的位置(resize,rehash)。

    HashMap的扩容机制非常消耗性能,如果可以提前能确定需要存到HashMap里的数据量的话,建议在创建对象时就确定其大小(2的幂次方,0.75负载因子这两点都要考虑)。

    实现方式

    jdk1.8之前实现方式

    数组+链表

    当添加数据时,先通过哈希函数计算出存放的位置,如果发生哈希碰撞,采用头插法插入到所在数组位置的链表头。

    头插法会让HashMap在并发操作时有可能发生一个bug:当并发时Rehash,那么就有极大概率让entry对象链表产生循环链表。

    jdk1.8实现方式

    数组+链表+红黑树

    相对于之前做出了一些优化:

    1.当一个结点的链表长度大于8时,entry对象链表会转换成红黑树,提高查询效率,而链表长度小于6时又会退化成链表 。

    2.采用了尾插法:避免了循环链表的产生。但有可能会产生数据覆盖。

    数组结构在查询和插入删除复杂度方面分别为O(1)和O(n)
    链表结构在查询和插入删除复杂度方面分别为O(n)和O(1)
    二叉树两者都为O(lgn)
    而哈希表两者都为O(1)
    

    HashMap的长度是2的整数次幂

    为了能让HashMap存取高效,尽量减少碰撞,也就是要尽量把数据分配均匀,Hash值的范围是-2147483648到2147483647,前后加起来有40亿的映射空间,只要哈希函数映射的比较均匀松散,一般应用是很难出现碰撞的,但一个问题是40亿的数组内存是放不下的。所以这个散列值是不能直接拿来用的。用之前需要先对数组长度取模运算,得到余数才能用来存放位置也就是对应的数组小标。这个数组下标的计算方法是(n-1)&hash,n代表数组长度

    这个算法应该如何设计呢?

    我们首先可能会想到采用%取余的操作来实现。但是,重点来了。

    取余操作中如果除数是2的幂次则等价于其除数减一的与操作,也就是说hash%length=hash&(length-1),但前提是length是2的n次方,并且采用&运算比%运算效率高,这也就解释了HashMap的长度为什么是2的幂次方。

    二.ConcurrentHashMap

    JDK1.7版本的CurrentHashMap的实现原理

    在JDK1.7中ConcurrentHashMap采用了数组+Segment+分段锁的方式实现。

    1.Segment(分段锁)

    ConcurrentHashMap中的分段锁称为Segment,它即类似于HashMap的结构,即内部拥有一个Entry数组,数组中的每个元素又是一个链表,同时又是一个ReentrantLock(Segment继承了ReentrantLock)。

    2.内部结构

    ConcurrentHashMap使用分段锁技术,将数据分成一段一段的存储,然后给每一段数据配一把锁,当一个线程占用锁访问其中一个段数据的时候,其他段的数据也能被其他线程访问,能够实现真正的并发访问

    从上面的结构我们可以了解到,ConcurrentHashMap定位一个元素的过程需要进行两次Hash操作。

    第一次Hash定位到Segment,第二次Hash定位到元素所在的链表的头部。

    3.该结构的优劣势

    坏处

    这一种结构的带来的副作用是Hash的过程要比普通的HashMap要长

    好处

    写操作的时候可以只对元素所在的Segment进行加锁即可,不会影响到其他的Segment,这样,在最理想的情况下,ConcurrentHashMap可以最高同时支持Segment数量大小的写操作(刚好这些写操作都非常平均地分布在所有的Segment上)。

    所以,通过这一种结构,ConcurrentHashMap的并发能力可以大大的提高。

    JDK1.8版本的CurrentHashMap的实现原理

    JDK8中ConcurrentHashMap参考了JDK8 HashMap的实现,采用了数组+链表+红黑树的实现方式来设计,内部大量采用CAS操作,这里我简要介绍下CAS。

    CAS是compare and swap的缩写,即我们所说的比较交换。cas是一种基于锁的操作,而且是乐观锁。在java中锁分为乐观锁和悲观锁。悲观锁是将资源锁住,等一个之前获得锁的线程释放锁之后,下一个线程才可以访问。而乐观锁采取了一种宽泛的态度,通过某种方式不加锁来处理资源,比如通过给记录加version来获取数据,性能较悲观锁有很大的提高。

    CAS 操作包含三个操作数 —— 内存位置(V)、预期原值(A)和新值(B)。如果内存地址里面的值和A的值是一样的,那么就将内存里面的值更新成B。CAS是通过无限循环来获取数据的,若果在第一轮循环中,a线程获取地址里面的值被b线程修改了,那么a线程需要自旋,到下次循环才有可能机会执行。

    JDK8中彻底放弃了Segment转而采用的是Node,其设计思想也不再是JDK1.7中的分段锁思想。

    Node:保存key,value及key的hash值的数据结构。其中value和next都用volatile修饰,保证并发的可见性。

    Java8 ConcurrentHashMap结构基本上和Java8的HashMap一样,不过保证线程安全性。

    在JDK8中ConcurrentHashMap的结构,由于引入了红黑树,使得ConcurrentHashMap的实现非常复杂,我们都知道,红黑树是一种性能非常好的二叉查找树,其查找性能为O(logN),但是其实现过程也非常复杂,而且可读性也非常差,Doug
    Lea的思维能力确实不是一般人能比的,早期完全采用链表结构时Map的查找时间复杂度为O(N),JDK8中ConcurrentHashMap在链表的长度大于某个阈值的时候会将链表转换成红黑树进一步提高其查找性能。

    其实可以看出JDK1.8版本的ConcurrentHashMap的数据结构已经接近HashMap,相对而言,ConcurrentHashMap只是增加了同步的操作来控制并发,从JDK1.7版本的ReentrantLock+Segment+HashEntry,到JDK1.8版本中synchronized+CAS+HashEntry+红黑树。

    总结:

    1.数据结构:取消了Segment分段锁的数据结构,取而代之的是数组+链表+红黑树的结构。
    2.保证线程安全机制:JDK1.7采用segment的分段锁机制实现线程安全,其中segment继承自ReentrantLock。JDK1.8采用CAS+Synchronized保证线程安全。
    3.锁的粒度:原来是对需要进行数据操作的Segment加锁,现调整为对每个数组元素加锁(Node)。
    4.链表转化为红黑树:定位结点的hash算法简化会带来弊端,Hash冲突加剧,因此在链表节点数量大于8时,会将链表转化为红黑树进行存储。
    5.查询时间复杂度:从原来的遍历链表O(n),变成遍历红黑树O(logN)。

    ConcurrentHashMap与HashTable

    hash table虽然性能上不如ConcurrentHashMap,但并不能完全被取代,两者的迭代器的一致性不同的,hash table的迭代器是强一致性的,而concurrenthashmap是弱一致的。 ConcurrentHashMap的get,clear,iterator 都是弱一致性的。

    • Hashtable的任何操作都会把整个表锁住,是阻塞的。好处是总能获取最实时的更新,比如说线程A调用putAll写入大量数据,期间线程B调用get,线程B就会被阻塞,直到线程A完成putAll,因此线程B肯定能获取到线程A写入的完整数据。坏处是所有调用都要排队,效率较低。
    • ConcurrentHashMap 是设计为非阻塞的。在更新时会局部锁住某部分数据,但不会把整个表都锁住。同步读取操作则是完全非阻塞的。好处是在保证合理的同步前提下,效率很高。坏处 是严格来说读取操作不能保证反映最近的更新。例如线程A调用putAll写入大量数据,期间线程B调用get,则只能get到目前为止已经顺利插入的部分 数据。

    选择哪一个,是在性能与数据一致性之间权衡。ConcurrentHashMap适用于追求性能的场景,大多数线程都只做insert/delete操作,对读取数据的一致性要求较低。Java对象保存在内存中时,由以下三部分组成:

    Java对象保存在内存中时的组成部分

    1,对象头

    2,实例数据

    3,对齐填充字节

    java的对象头由以下三部分组成:

    1,Mark Word

    2,指向类的指针

    3,数组长度(只有数组对象才有)

    对象头Mark Word:

    biased_lock lock 状态
    0 01 无锁
    1 01 偏向锁
    0 00 轻量级锁
    0 10 重量级锁
    0 11 GC标记

    synchronized的底层实现

    通过对象内部的一个叫做监视器锁(monitor)来实现的,监视器锁本质又是依赖于底层的操作系统的Mutex Lock(互斥锁)来实现的。而操作系统实现线程之间的切换需要从用户态转换到核心态,这个成本非常高,状态之间的转换需要相对比较长的时间,这就是为什么Synchronized效率低的原因。因此,这种依赖于操作系统Mutex Lock所实现的锁我们称之为“重量级锁”。

    jdk1.5之后synchronized的优化

    锁升级

    上面讲到锁有四种状态,并且会因实际情况进行膨胀升级,其膨胀方向是:无锁——>偏向锁——>轻量级锁——>重量级锁,并且膨胀方向不可逆。

    偏向锁

    一句话总结它的作用:减少统一线程获取锁的代价。在大多数情况下,锁不存在多线程竞争,总是由同一线程多次获得,那么此时就是偏向锁。

    核心思想:

    如果一个线程获得了锁,那么锁就进入偏向模式,此时Mark Word的结构也就变为偏向锁结构,当该线程再次请求锁时,无需再做任何同步操作,即获取锁的过程只需要检查**Mark Word**的锁标记位为偏向锁以及当前线程ID等于**Mark Word**的ThreadID即可,这样就省去了大量有关锁申请的操作。

    轻量级锁

    轻量级锁是由偏向锁升级而来,当存在第二个线程申请同一个锁对象时,偏向锁就会立即升级为轻量级锁。注意这里的第二个线程只是申请锁,不存在两个线程同时竞争锁,可以是一前一后地交替执行同步块。

    重量级锁

    重量级锁是由轻量级锁升级而来,当同一时间有多个线程竞争锁时,锁就会被升级成重量级锁,此时其申请锁带来的开销也就变大。

    重量级锁一般使用场景会在追求吞吐量,同步块或者同步方法执行时间较长的场景。

  • 相关阅读:
    LeetCode5 Longest Palindromic Substring
    LeetCode4 Median of Two Sorted Arrays
    LeetCode3 Longest Substring Without Repeating Characters
    LeetCode2 Add Two Numbers
    LeetCode1 Two Sum
    算法总结—深度优先搜索DFS
    c++ 设计模式9 (Abstract Factory 抽象工厂模式)
    题解 P3374 【【模板】树状数组 1】
    题解 HDU1565 【方格取数(1)】
    题解 P2431 【正妹吃月饼】
  • 原文地址:https://www.cnblogs.com/lzxulxy/p/12493931.html
Copyright © 2020-2023  润新知