HashMap、synchronized

HashMap

HashMap

JDK1.7中: 数组+链表

使用一个Entry数组来存储数据,用key的hashcode取模来决定key会被放到数组里的位置,如果通过key计算出来的hashcode,对数组长度取模定位到的位置相同,就产生了hash冲突,hashmap解决hash冲突的方法是链地址法,那么定位到同一个位置的entry节点就会形成一个链表

hash函数性能特别差的情况下(生成的hash值散列的不均匀),这个链表可能会很长,那么put/get操作都可能需要遍历这个链表,也就是最差情况下时间复杂度为O(n)

JDK1.8中: 数组+链表或红黑树

使用一个Node数组来存储数据,如果插入的元素key的hashcode值相同,那么这些key也会被定位到Node数组的同一个格子里,如果不超过8个,使用链表存储,超过8个,(将链表转换成红黑树前会判断,如果当前数组的长度小于 64,那么会选择先进行数组扩容,而不是转换为红黑树) 将链表转换为红黑树。那么即使所有key的hashcode完全相同,由于红黑树的特点,查找某个特定元素,也只需要O(logn) 的开销。

链表的插入方式从头插法改成了尾插法,简单说就是插入时,如果数组位置上已经有元素,1.7将新元素放到数组中,原始节点作为新节点的后继节点,1.8遍历链表,将元素放置到链表的最后

为什么要做这几点优化;

1、数组+链表改成了数组+链表或红黑树;

为了: 防止发生hash冲突,链表长度过长,将时间复杂度由O(n)降为O(logn);

2、链表的插入方式从头插法改成了尾插法

为了: 因为1.7头插法扩容时,头插法会使链表发生反转,多线程环境下会产生环;

在这里插入图片描述

基本参数

  1. 数组的=初始容量为16,而容量是以2的次方扩充的,一是为了提高性能使用足够大的数组,二是为了能使用位运算代替取模预算。
  2. 数组是否需要扩充是通过负载因子判断的,如果当前元素个数为数组容量的0.75时,就会扩充数组。这个0.75就是默认的负载因子。可由构造传入。

get

  • 通过 hash & (table.length - 1)获取该key对应的数据节点的hash槽
  • 判断首节点是否为空, 为空则直接返回空;不为空的时候
  • 再判断首节点.key 是否和目标值相同, 相同则直接返回,不等
  • 首节点是树形节点, 则进入红黑树数的取值流程, 并返回结果
  • 进入链表的取值流程, 并返回结果;

put

当添加一个元素(key-value)时,就首先计算元素key的hash值,并根据hash值来确定插入数组中的位置,但是可能存在其他元素已经被放在数组同一位置了,产生了hash冲突

解决办法: 采用链式地址法,对于相同的值,使用链表进行连接。当链表长度到达一个阈值时(>=8)将链表转换成红黑树提高查询性能。而当链表长度缩小到另一个阈值时(<=6),又会将红黑树转换回单向链表提高性能,这里是一个平衡点。当个数不多的时候,直接链表遍历更方便,实现起来也简单。而红黑树的实现要复杂的多。这个时候便使用链表来解决哈希冲突,当链表长度太长的时候,便将链表转换为红黑树来提高搜索的效率
在这里插入图片描述

扩容

HashMap 按当前桶数组长度的2倍进行扩容,阈值也变为原来的2倍,扩容之后,要重新计算键值对的位置,并把它们移动到合适的位置上去。

  • 在插入时,1.7先判断是否需要扩容,再插入,1.8先进行插入,插入完成再判断是否需要扩容;
  • 扩容的时候1.7需要对原数组中的元素进行重新hash定位在新数组的位置,1.8采用更简单的判断逻辑,位置不变或索引+旧容量大小

HashMap的哈希函数(这个也叫扰动函数)

hash函数是先拿到 key 的hashcode,是一个32位的int值,然后让hashcode的高16位和低16位进行异或操作。

在这里插入图片描述

  • 一定要尽可能降低hash碰撞,越分散越好;
  • 算法一定要尽可能高效,因为这是高频操作, 因此采用位运算;

为什么采用hashcode的高16位和低16位异或能降低hash碰撞?hash函数能不能直接用key的hashcode?

因为key.hashCode()函数调用的是key键值类型自带的 哈希函数,返回int型散列值。 int值范围为-2147483648~2147483647,前后加起来大概40亿的映射空间。只要哈希函数映射得比较均匀松散,一般应用是很难出现碰撞的。但问题是一个40亿长度的数组,内存是放不下的。你想,如果HashMap数组的初始大小才16,用之前需要==对数组的长度取模运算,得到的余数才能用来访问数组下标。==这个数组下标的计算方法是“ (n - 1) & hash”。

源码中模运算就是把散列值和数组长度-1做一个"与"操作,位运算比取余%运算要快。

取余(%)操作中如果数组是2的幂次等价于与其数组长度减一的与(&)操作(也就是说 hash%lengthhash&(length-1)的前提是 length 是2的 n 次方==;)。” 并且 采用二进制位操作 &,相对于%能够提高运算效率,这就解释了 HashMap 的长度为什么是2的幂次方。

bucketIndex = indexFor(hash, table.length);

static int indexFor(int h, int length) {
     return h & (length-1);
}

顺便说一下,这也正好解释了为什么HashMap的数组长度要取2的整数幂。因为这样(数组长度-1)正好相当于一个“低位掩码”。“与”操作的结果就是散列值的高位全部归零,只保留低位值,用来做数组下标访问。以初始长度16为例,16-1=15。2进制表示是00000000 00000000 00001111。和某散列值做“与”操作如下,结果就是截取了最低的四位值。

  10100101 11000100 00100101
& 00000000 00000000 00001111
----------------------------------
  00000000 00000000 00000101    //高位全部归零,只保留末四位

这样就算我的散列值分布再松散,要是只取最后几位的话,碰撞也会很严重。

这时候“扰动函数”的价值就体现出来了,右移16位,正好是32bit的一半,自己的高半区和低半区做异或,就是为了混合原始哈希码的高位和低位,以此来加大低位的随机性。


那HashMap是线程安全的吗?

不是,在多线程环境下,1.7 在扩容的时候会产生死循环(transfer方法),数据丢失的问题,1.8 中为了解决死循环的问题,采用了尾插法代替头插法,但还是会有数据覆盖的问题, 以1.8为例,当A线程判断index位置为空后正好挂起,B线程开始往index位置的写入节点数据,这时A线程恢复现场,执行赋值操作,就把A线程的数据给覆盖了;还有++size这个地方也会造成多线程同时扩容等问题。

那你平常怎么解决这个线程不安全的问题?

Java中有HashTable、Collections.synchronizedMap、以及ConcurrentHashMap可以实现线程安全的Map。

HashTable是直接在操作方法上加synchronized关键字,锁住整个数组,粒度比较大,Collections.synchronizedMap是使用Collections集合工具的内部类,通过传入Map封装出一个SynchronizedMap对象,内部定义了一个对象锁,方法内通过对象锁实现;

ConcurrentHashMap使用分段锁,降低了锁粒度,让并发度大大提高。

ConcurrentHashMap

JDK1.7 的时候,底层采用 Segment 分段锁实现,(segement在实现上继承了ReetrantLock)当一个线程占用锁访问其中一个段数据时,其他段的数据也能被其他线程访问,每一把锁只锁容器其中一部分数据,多线程访问容器里不同数据段的数据,就不会存在锁竞争,提高并发访问率。

Segment 默认是16个段(所以支持最高并发度是16)。

segement在实现上继承了ReetrantLock,这样就自带了锁的功能。,而且Segment 是一种可重入锁

到了 JDK1.8 的时候已经摒弃了 Segment 的概念(链表遍历复杂度慢),采用 CAS 和 synchronized 来保证并发安全,多线程操作只会锁住当前操作索引的节点。

数据结构跟 HashMap1.8 的结构类似,采用 Node 数组+链表+红黑树的数据结构来实现,在链表长度超过一定阈值(8)时将链表(寻址时间复杂度为 O(N))转换为红黑树(寻址时间复杂度为 O(log(N)))

JDK1.6以后 对 synchronized锁做了很多优化。
synchronized是靠对象的对象头和此对象的monitor来保证上锁的,monitor内部保存了一个当前线程,也就是抢到锁的线程。在ConcurrentHashMap 1.8中,这里锁的是链表的头节点,该元素在node数组中,所以锁的就是hash冲突的那条链表,这里就是将锁细化了,除非两个线程同时操作一个node,才会争抢同一把锁。
所以这时候出现并发争抢的可能性会很低,只要线程可以在30到50次自旋里拿到锁,那么Synchronized就不会升级为重量级锁,而等待的线程也就不用被挂起,我们也就少了挂起和唤醒这个上下文切换的过程开销。

get、put

put
一开始得检查桶是否进行初始化,然后确定判断所放桶中是否有元素,没有元素的话,那么就用CAS的方式添加元素,

当然有可能失败,有可能其他线程已经抢占先添加,这个过程在一个死循环里,失败了再从头来一次,判断桶是否初始化,桶内是否有元素等等,

如果此时桶内有元素了,那么就对一个元素synchronized上锁,上锁之后还要对桶内第一个元素判断是否发生了改变,发生变化了就再从头再来。如果第一元素是链表节点,那么遍历链表查询是否key相等,若查到把value更改为我们要put的value值,没找到的话新建节点插入到链表尾部。若第一个节点是树节点,那么就以树的方式插入。

我对再次判断桶内第一个元素的理解:如果在加锁的时候,此时有可能其他线程删除了第一个元素,那么就产生了错误。经历了重重困难终于能插入数据了,此时就和HashMap有点像了,

get

检查桶的长度是否为空,或者根据key得到具体哪个桶中没有数据,那么直接返回null,如果桶不为空且要查询的桶中有元素,那么桶中第一个元素的hash值如果大于0说明为链表节点,那么遍历链表查询即可,小于0说明是树节点或者正在扩容,那么调用Node子类的find函数进行查询。

get操作全程不需要加锁是因为Node的成员val是用volatile修饰

happen before原则:JMM可以通过happen before关系向程序员提供跨线程的内存可见性保证(如果A线程的写操作a和B线程的读操作b之间存在happen before关系,尽管a操作和b操作在不同的线程中执行,但JMM向程序员保证a操作将对b操作可见)

Hashtable

Hashtable 和 JDK1.8 之前的 HashMap 的底层数据结构类似都是采用 数组+链表的形式,数组是 HashMap 的主体,链表则是主要为了解决哈希冲突而存在的;

Hashtable(同一把锁) :使用 synchronized 来保证线程安全,效率非常低下。当一个线程访问同步方法时,其他线程也访问同步方法,可能会进入阻塞或轮询状态,如使用 put 添加元素,另一个线程不能使用 put 添加元素,也不能使用 get,竞争会越来越激烈效率越低。

在这里插入图片描述

LinkedHashMap(有序)

LinkedHashMap 继承自 HashMap,所以它的底层仍然是基于拉链式散列结构即由数组和链表或红黑树组成。

另外,LinkedHashMap 在上面结构的基础上,增加了一条双向链表,有头尾节点,同时LinkedHashMap节点Entry内部除了继承HashMap的Node属性,还有before 和 after用于标识前置节点和后置节点。使得上面的结构可以保持键值对的插入顺序

TreeMap(有序)

TreeMap是按照Key的自然顺序或者Comprator的顺序进行排序,内部是通过红黑树来实现。所以要么key所属的类实现Comparable接口,或者自定义一个实现了Comparator接口的比较器,传给TreeMap用于key的比较。

HashMap 和 Hashtable 的区别

1、线程是否安全HashMap 是非线程安全的HashTable 是线程安全的,所以效率低,因为 HashTable 内部的方法基本都经过synchronized 修饰。

2、对 Null key 和 Null value 的支持: HashMap 可以存储 null 的 key 和 value,但 null 作为键只能有一个,null 作为值可以有多个;HashTable 不允许有 null 键和 null 值,否则会抛出 NullPointerException。

3、初始容量大小和每次扩充容量大小的不同 : Hashtable 会直接使用你给定的大小, 创建时如果给定了容量初始值,默认的初始大小为11,之后每次扩充,容量变为原来的 2n+1。

HashMap 默认的初始化大小为 16。之后每次扩充,容量变为原来的 2 倍。也可以指定初始容量,如果指定大小不是2 的幂次方,HashMap 会将其扩充为 2 的幂次方大小。

3、底层数据结构: JDK1.8 以后的 HashMap 在解决哈希冲突时有了较大的变化,当链表长度大于阈值(默认为 8)时,将链表转化为红黑树,以减少搜索时间。Hashtable 没有这样的机制。
(将链表转换成红黑树前会判断,如果当前数组的长度小于 64,那么会选择先进行数组扩容,而不是转换为红黑树)

List,Set,Map三者的区别?

1、List(对付顺序的好帮手): 存储的元素是有序的、可重复的。

  • Arraylist: 底层使用的是 Object 数组,不保证线程安全
    ArrayList 采用数组存储,所以插入和删除元素的时间复杂度受元素位置的影响。例如:执行add(E e)时,默认插入到列表的尾部,这种情况时间复杂度就是 O(1)。但是如果要在指定位置 i 插入和删除元素的话(add(int index, E element))时间复杂度就为 O(n-i)。因为在进行上述操作的时候集合中第 i 和第 i 个元素之后的(n-i)个元素都要执行向后位移一位的操作。
    支持高效的随机元素访问,通过元素的序号快速获取元素对象(对应于get(int index)方法)。

  • LinkedList: 底层使用的是双向链表,不保证线程安全
    LinkedList 采用链表存储,所以对于add(E e)方法的插入,删除元素时间复杂度不受元素位置的影响,近似 O(1),如果是要在指定位置i插入和删除元素的话((add(int index, E element)) 时间复杂度近似为o(n))因为需要先移动到指定位置再插入
    不支持高效的随机元素访问

2、Set(注重独一无二的性质): 存储的元素是无序的、不可重复的。

  • HashSet(无序,唯一): 基于 HashMap 实现的,底层采用 HashMap 来保存元素,仅存储对象
    当你把对象加入HashSet时,HashSet 会先计算对象的hashcode值来判断对象加入的位置,同时也会与其他加入的对象的 hashcode 值作比较,但是如果发现有相同 hashcode 值的对象,这时会调用equals()方法来检查 hashcode 相等的对象是否真的相同。如果两者相同,HashSet 就不会让加入操作成功。
    如果两个对象相等,则 hashcode 一定也是相同的
    两个对象有相同的 hashcode 值,它们也不一定是相等的

  • LinkedHashSet:LinkedHashSet 是 HashSet 的子类,并且其内部是通过 LinkedHashMap 来实现的。有点类似于我们之前说的 LinkedHashMap 其内部是基于 HashMap 实现一样,不过还是有一点点区别的,能够按照添加的顺序遍历

  • TreeSet(有序,唯一): 底层使用红黑树,能够按照添加元素的顺序进行遍历,红黑树(自平衡的排序二叉树)

3、Map(用 Key 来搜索的专家): 使用键值对(kye-value)存储,类似于数学上的函数 y=f(x),“x”代表 key,"y"代表 value,Key 是无序的、不可重复的,value 是无序的、可重复的,每个键最多映射到一个值。

  • HashMap: JDK1.8 之前 HashMap 由数组+链表组成的,数组是 HashMap 的主体,链表则是主要为了解决哈希冲突而存在的(“拉链法”解决冲突)。JDK1.8 以后在解决哈希冲突时有了较大的变化,当链表长度大于阈值(默认为 8)(将链表转换成红黑树前会判断,如果当前数组的长度小于 64,那么会选择先进行数组扩容,而不是转换为红黑树)时,将链表转化为红黑树,以减少搜索时间

  • LinkedHashMap: LinkedHashMap 继承自 HashMap,所以它的底层仍然是基于拉链式散列结构即由数组和链表或红黑树组成。另外,LinkedHashMap 在上面结构的基础上,增加了一条双向链表,使得上面的结构可以保持键值对的插入顺序。

  • Hashtable: 数组+链表组成的,数组是 HashMap 的主体,链表则是主要为了解决哈希冲突而存在的

  • TreeMap: 红黑树(自平衡的排序二叉树)

红黑树:
1、每个节点或者是黑色,或者是红色。
2、根节点是黑色。
3、每个叶子节点(NIL)是黑色。 [注意:这里叶子节点,是指为空(NIL或NULL)的叶子节点!]
4、如果一个节点是红色的,则它的子节点必须是黑色的。
5、从一个节点到该节点的子孙节点的所有路径上包含相同数目的黑节点。

synchronized

在某些多线程场景下,当一个资源有可能被多个线程同时访问并修改的话,如果不进行同步会导致数据不安全,

Synchronized是JVM提供的一个同步关键字,可以解决多个线程之间访问资源的同步性,并且可以保证被它修饰的方法或者代码块在任意时刻只能有一个线程执行

Synchronized在JVM中的实现原理,JVM基于进入和退出Monitor对象来实现方法同步和代码块同步。

三种使用方式

1、在静态方法上加锁;类对象 => SynchronizedSample.class

也就是给当前类加锁,会作用于类的所有对象实例 ,进入同步代码前要获得当前 class 的锁。

2、在非静态方法上加锁;对当前对象实例加锁 => this

静态方法和方法上加锁是在方法的flags 中加入 ACC_SYNCHRONIZED 标识位

该标识指明了该方法是一个同步方法。 JVM 通过该标志来辨别一个方法是否声明为同步方法,从而执行相应的同步调用。

3、在代码块上加锁;对给定对象加锁 => lock

synchronized 在代码块上是通过monitorenter 和 monitorexit指令实现,monitorenter 指令指向同步代码块的开始位置,monitorexit 指令则指明同步代码块的结束位置。

当执行 monitorenter 指令时,线程试图获取锁也就是获取对象监视器 monitor 的持有权

Synchronized是非公平锁,Synchronized在线程竞争锁时,首先做的不是直接进队列排队,而是尝试自旋获取锁,如果获取不到才进入队列排队,这明显对于已经进入队列的线程是不公平的;

锁优化

JDK 6之前, synchronized 属于重量级锁,效率低下。synchronized 重量级锁有以下二个问题

  • 因为监视器锁(monitor)依赖底层操作系统的相关指令实现(mutex),加锁解锁需要在用户态和内核态之间切换,这个状态之间的转换需要相对比较长的时间,时间成本相对较高。
  • 大多数对象的加锁和解锁都是在同一个线程中完成的。也就是出现线程竞争锁的情况概率比较低。

JDK1.6 对锁的实现引入了大量的优化,如偏向锁、自旋锁和轻量级锁等技术来减少锁操作的开销。

锁升级(四种状态)

锁主要存在四种状态,依次是:无锁状态、偏向锁状态、轻量级锁状态、重量级锁状态。而且锁只能升级不能锁降级。

首先处于无锁状态的时候,来了一个A线程,使用CAS 操作将 Thread ID 放到 Mark Word 当中,如果CAS 成功, 此时线程A 就获取了锁,同时修改偏向锁的标志位,就会把对象的对象头中偏向锁的状态设置成1,表示加上偏向锁,同时也可以看到哪个线程获得了该对象的锁。如果线程B来 CAS 失败,证明有别的线程持有锁,这个时候启动偏向锁撤销,升级为轻量级锁,锁标志位变成 00。

重量级锁是指当锁为轻量级锁的时候,如果依然有新线程过来竞争锁,首先新线程会自旋尝试获取锁,自旋锁主要是为了降低线程切换的成本,尝试到一定次数(默认10次)依然没有拿到,锁就会升级成重量级锁。

一般来说,同步代码块内的代码应该很快就执行结束,这时候线程B 自旋一段时间是很容易拿到锁的,如果一直没拿到,自旋其实就是死循环,很耗CPU的,因此就直接转成重量级锁咯,这样就不用让线程一直自旋了。这就是锁膨胀的过程


偏向锁撤销: 遍历线程栈,查看是否有被锁对象的锁记录需要修复锁记录和Markword,使其变成无锁状态。将是否为偏向锁状态置为 0 ,开始进行轻量级加锁流程

volatile

volatile可以实现内存的可见性和防止指令重排序,但是volatile 不保证操作的原子性。如果一个字段被声明为volatile,java线程内存模型确保所有线程看到这个变量的值是一致的。
其实volatile的这些内存语意是通过内存屏障技术实现的

内存屏障效果有:

  • 对 volatile 变量进行写操作时,会在写操作后加一条 store 屏障指令,将工作内存中的共享变量刷新回主内存;
  • 对 volatile 变量进行读操作时,会在读操作前加一条 load屏障指令,从主内存中读取共享变量;

ThreadLocal(解决多线程间的数据隔离问题)

通常情况下,我们创建的变量是可以被任何一个线程访问并修改的。如果想实现每一个线程都有自己的专属本地变量,ThreadLocal 类用于解决多线程间的数据隔离问题,也就是说ThreadLocal 会为每一个线程创建一个单独的变量副本。把共享数据的可见范围限制在同一个线程之内,因此 ThreadLocal 是线程安全的,每个线程都有属于自己的变量。

ThreadLocal,是一个以ThreadLocal为键,任意对象为值的存储结构,ThreadLocal 最终的变量是放在了当前线程的ThreadLocalMap 中,以ThreadLocal 为 key ,Object对象为 value 的键值对。每个线程可以根据一个ThreadLocal对象查询到绑定在这个线程上的一个值。可以通过set方法设置一个值,在当前线程下再通过get方法获取到原先设置的值。

ThreadLocal有什么缺陷?

ThreadLocalMap中使用的key 为 ThreadLocal 的弱引用(弱引用,生命周期只能存活到下次GC前 ),而value 是强引用。所以,如果没有被外部强引用的情况下,在垃圾回收的时候,key 会被清理掉,而 value 不会被清理掉。这样一来, ThreadLocalMap 中就会出现key为null的Entry。假如我们不做任何措施的话,value 永远无法被GC 回收,这个时候就可能会产生内存泄露。两种解决办法。

1、使用完 ThreadLocal 方法后 最好手动调用remove() 方法。
2、ThreadLocal定义为private static,所有此 类实例共享此静态变量,也就是说在类第一次被使用时装载,只分配一块存储空间,避免每个线程从任务队列中获取task后重复创建ThreadLocal所关联的对象。 所有此类的对象(只要是这个线程内定义的)都可以操控这个变量。

使用场景:

  • ThreadLocal 可以用来管理 Session,因为每个人的信息都是不一样的,所以就很适合用 ThreadLocal 来管理;
  • 数据库连接,为每一个线程分配一个独立的资源,也适合用 ThreadLocal来实现。将connection放进threadlocal里的,以保证每个线程从连接池中获得的都是线程自己的connection。

AtomicInteger(CAS)

原子性:操作是指==一个操作是不可中断的,要么成功,要么失败。==即使是在多个线程一起执行的时候,一个操作一旦开始,就不会被其他线程干扰。

原子类主要利用 CAS自旋实现原子操作的更新

CAS在多线程中,不需要锁的情况下,保证线程一致性的去修改某一个值,是一种乐观锁。CAS包含三个参数 。V表示要更新的变量,E表示预期的值,N表示新值。只有在要更新的变量值等于预期的值时,才会将要更新的变量值的值设置成新值,否则什么都不做。

另外 value 是一个volatile变量,在内存中可见,因此 JVM 可以保证任何时刻任何线程总能拿到该变量的最新值。compareAndSwapInt是一个本地native方法。

CAS存在的问题:

1、循环时间长开销大的问题:是因为CAS操作长时间不成功的话,会导致一直自旋,相当于死循环了,CPU的压力会很大。

2、ABA问题: 如果一个线程把当前值A,先修改成B,再修改回成A。当前线程的CAS操作无法分辨当前值是否发生过变化。加版本号可以解决

synchronized 关键字和 volatile 关键字的区别

  • volatile 关键字是线程同步的轻量级实现,所以volatile性能肯定比synchronized关键字要好。但是volatile 关键字只能用于变量synchronized 关键字可以修饰方法以及代码块
  • volatile 关键字能保证数据的可见性,但不能保证数据的原子性。synchronized 关键字两者都能保证
  • volatile关键字主要用于解决变量在多个线程之间的可见性,而 synchronized 关键字解决的是多个线程之间访问资源的同步性

ReentrantLock

ReentrantLock也是独占锁(只有一个线程能执行)加锁和解锁的过程需要手动进行

ReentrantLock主要利用CAS+AQS队列来实现。它支持公平锁和非公平锁 ,默认是非公平,构造方法中传入参数true就是公平锁

公平锁:按照线程在队列中的排队顺序,先到者先拿到锁
非公平锁:当线程要获取锁时,无视队列顺序直接去抢锁,谁抢到就是谁的

ReetrantLock还提供了其它功能,包括Condition,可以实现线程的精确等待和唤醒,操作更加灵活,不过ReetrantLock需要显示的获取锁,并在finally中释放锁,否则可能会造成死锁


synchronized和ReentrantLock的区别

synchronized是独占锁,加锁和解锁的过程自动进行,易于操作,但不够灵活。ReentrantLock也是独占锁(只有一个线程能执行)加锁和解锁的过程需要手动进行,不易操作,但非常灵活。

synchronized可重入,因为加锁和解锁自动进行,不必担心最后是否释放锁;ReentrantLock也可重入,但加锁和解锁需要手动进行,且次数需一样,否则其他线程无法获得锁。

synchronized不可响应中断,一个线程获取不到锁就一直等着;ReentrantLock 可以响应中断

由于synchronized是在JVM层面实现的,因此系统可以监控锁的释放与否;而ReentrantLock是使用代码实现的,系统无法自动释放锁,需要在代码中的finally子句中显式释放锁lock.unlock()。

另外,在并发量比较小的情况下,使用synchronized是个不错的选择;但是在并发量比较高的情况下,其性能下降会很严重,此时ReentrantLock是个不错的方案。


AQS

AbstractQueuedSynchronizer是一个用来构建锁和同步器的框架

AQS 使用一个volatitle修饰的int成员变量state来表示同步状态,值为1 时表示有线程占用,其他线程需要进入到同步队列等待,通过内置的先进先出的双向队列来完成获取资源线程的排队工作。状态信息使用CAS 对该同步状态进行原子操作实现对其值的修改

AQS 核心思想是,如果被请求的共享资源空闲,则将当前请求资源的线程设置为有效的工作线程,并且将共享资源设置为锁定状态。如果被请求的共享资源被占用将暂时获取不到锁的线程加入到队列中。

AQS 定义两种资源共享方式:

  • Exclusive(独占):只有一个线程能执行,如 ReentrantLock。
  • Share(共享):多个线程可同时执行,如CountDownLatch、Semaphore、ReadWriteLock

AQS中定义的一些方法默认是没有实现的,需要子类去实现,这种方式就是模板方法模式。如果需要自定义同步器在实现时,使用者继承 AbstractQueuedSynchronizer 并重写指定的方法,对共享资源 state 的获取与释放方式即可(tryAcquire,tryRelease),至于具体线程等待队列的维护(如获取资源失败入队/唤醒出队等),AQS 已经在顶层实现好了。

以 ReentrantLock 为例,state 初始化为 0,表示未锁定状态。A 线程 lock()时,会调用 tryAcquire()独占该锁并将 state+1。此后,其他线程再 tryAcquire()时就会失败,直到 A 线程 unlock()到 state=0(即释放锁)为止,其它线程才有机会获取该锁。当然,释放锁之前,A 线程自己是可以重复获取此锁的(state 会累加),这就是可重入的概念。但要注意,获取多少次就要释放多么次,这样才能保证 state 是能回到零态的。

再以 CountDownLatch 以例,任务分为 N 个子线程去执行,state 也初始化为 N(注意 N 要与线程个数一致)。这 N 个子线程是并行执行的,每个子线程执行完后countDown() 一次,state 会 CAS(Compare and Swap)减 1。等到所有子线程都执行完后(即 state=0),会 unpark()主调用线程,然后主调用线程就会从 await() 函数返回,继续后余动作。


死锁

线程 A 持有资源 2,线程 B 持有资源 1,他们同时都想申请对方的资源,所以这两个线程就会互相等待而进入死锁状态。

产生死锁的必要条件:

  • 互斥条件:即在一段时间内某资源只由一个进程占用。如果此时还有其它进程请求该资源,则请求者只能等待,直至占有该资源的进程用毕释放。
  • 请求和保持条件:指进程已经保持了至少一个资源,但又提出了新的资源请求,而该资源又被其它进程占有,此时请求进程阻塞,但又对自己获得的其它资源保持不放。
  • 不剥夺条件:指进程已获得资源,在使用完之前,不能被剥夺,只能在使用完时由自己释放。
  • 循环等待条件:指在发生死锁时,必然存在一个进程—资源的环形链

解决死锁的策略:

1、死锁预防:破坏导致死锁必要条件中的任意一个就可以预防死锁。

  • 破坏保持和等待条件:就是系统中不允许进程在获得某种资源的情况下,申请其他资源。即阻止进程在持有资源的同时申请其他资源。
  • 破坏不可剥夺条件:如果占有某些资源的一个进程进行进一步资源请求被拒绝,则该进程必须释放它最初占有的资源
  • 破坏循环等待条件: 按某一顺序申请资源,释放资源则反序释放。(是将系统中的所有资源统一编号,进程可在任何时刻提出资源申请,但所有申请必须按照资源的编号顺序(升序)提出。这样做就能保证系统不出现死锁。)

2、死锁避免:加锁时限(线程尝试获取锁的时候加上一定的时限,超过时限则放弃对该锁的请求,并释放自己占有的锁)

3、死锁解除: 将某进程所占资源进行强制回收,然后分配给其他进程。

jps 可以定位进程号
jstack 加进程号可以找到死锁的信息


公平锁/非公平锁(ReentrantLock)

公平锁:按照线程在队列中的排队顺序,先到者先拿到锁
非公平锁:当线程要获取锁时,无视队列顺序直接去抢锁,谁抢到就是谁的

ReentrantLock默认是非公平,构造方法中传入参数true就是公平锁


可重入锁(ReentrantLock、Synchronized)

可重入锁又名递归锁,是指在同一个线程在外层方法获取锁的时候,在进入内层方法会自动获取锁。
对于 ReentrantLock、Synchronized,都是一个可重入锁。可重入锁的一个好处是可一定程度避免死锁。


读写锁(可重入,ReadWriteLock)

ReadWriteLock就是读写锁,它是一个接口,ReentrantReadWriteLock 实现了这个接口。可以通过readLock()获取读锁,通过writeLock()获取写锁。,读写锁是用AQS来实现的,都是继承sync类,主要基于int类型的state关键字表示锁的状态,状态为0表示锁空闲。 并且该锁是一个可重入锁。

公平性有两种情况: 当前是读锁,后面再有读锁进来的话,就直接可以获得这个锁,相当于非公平锁。但是有一直情况,读锁,写锁,再来一个读锁,这时候读锁就需要排队了,如果它还可以去抢的话,那么这个写锁就没有机会了,就会被饿死了,所以就是公平锁。

其读锁是共享锁,就是在同一时刻可以允许多个读线程访问,
其写锁是独占锁,在写线程访问时,所有的读线程和写线程均被阻塞。


乐观锁/悲观锁(synchronized、ReentrantLock都是悲观锁)

对于同一个数据的并发操作,悲观锁采取加锁的形式。悲观的认为,不加锁的并发操作 一定会出问题。

乐观的认为,不加锁的并发操作是安全的。当多个线程尝试使用CAS同时更新同一个变量时,只有其中一个线程能更新变量的值,而其它线程都失败,失败的线程并不会被挂起,而是被 告知这次竞争中失败,并可以再次尝试

synchronized、ReentrantLock都是悲观锁


可中断锁(Lock)

就是可以相应中断的锁。在Java中,synchronized就不是可中断锁,而Lock是可中断锁。如果某一线程A正在执行锁中的代码,另一线程B正在等待获取该锁,可能由于等待时间过长,线程B不想等待了,想先处理其他事情,我们可以让它中断自己,这种就是可中断锁。


锁粗化、锁消除

1、锁粗化:StringBuffer时线程安全的,它的方法是被synchronized修饰的,如果在for循环100次执行append,没有锁粗化就要对这同一个对象进行100次的加锁解锁操作,此时jvm就会将加锁的范围粗话到for循环外面,使得这100次操作只需要加一次锁即可。

2、锁消除:StringBuffer时线程安全的,它的方法是被synchronized修饰的,如果给StringBuffer外层的方法已经加了锁,这时候内部就是线程安全的,JVM就会自动消除StringBuffer内部的锁。


Semaphore(信号量)

Semaphore(信号量)可以指定多个线程同时访问某个资源。共享锁的一种实现。

它默认构造AQS 的 state 为 许可证。当执行任务的线程数量超出 许可证数量,那么多余的线程将会被放入阻塞队列 Park,并自旋判断 state 是否大于 0。只有当 state 大于 0 的时候,阻塞的线程才能继续执行,此时先前执行任务的线程继续执行 release() 方法,release() 方法使得 state 的变量会加 1,那么自旋的线程便会判断成功。 如此,每次只有最多不超过 permits 数量的线程能自旋成功,便限制了执行任务线程的数量


CountDownLatch(倒计时器)

CountDownLatch 是一个同步工具类,用来协调多个线程之间的同步。这个工具通常用来控制线程等待,某一线程在开始运行前等待 n 个线程执行完毕。将 CountDownLatch 的计数器初始化为 n,每当一个任务线程执行完毕,就将计数器减 1 == 当计数器的值变为 0 时==,在CountDownLatch上 await() 的线程就会被唤醒。*

CountDownLatch 是共享锁的一种实现,它默认构造 AQS 的 state 值为计数器
当线程使用 countDown() 方法时,其实使用了tryReleaseShared方法以CAS 的操作来减少 state 。当调用 await() 方法的时候,如果 state 不为 0,那就证明任务还没有执行完毕,await() 方法就会一直阻塞,也就是说 await() 方法之后的语句不会被执行。然后,CountDownLatch 会自旋 CAS 判断 state = 0,如果 state = 0 的话,就会释放所有等待的线程,await() 方法之后的语句得到执行。

CountDownLatch 是一次性的,计数器的值只能在构造方法中初始化一次,之后没有任何机制再次对其设置值,当 CountDownLatch 使用完毕后,它不能再次被使用。

使用场景:一个典型应用场景就是启动一个服务时,主线程需要等待多个组件加载完毕,之后再继续执行。*


CyclicBarrier(循环栅栏)

CyclicBarrier 和 CountDownLatch 非常类似,它也可以实现线程间的技术等待,但是它的功能比 CountDownLatch 更加复杂和强大。CyclicBarrier 的字面意思是可循环使用的屏障。它要做的事情是,让一组线程到达一个屏障时被阻塞,直到最后一个线程到达屏障时,屏障才会开门,所有被屏障拦截的线程才会继续干活。CyclicBarrier 默认的构造方法是 CyclicBarrier(int parties),其参数表示屏障拦截的线程数量,每个线程调用 await() 方法告诉 CyclicBarrier 我已经到达了屏障,然后当前线程被阻塞。

使用场景:CyclicBarrier 可以用于多线程计算数据,最后合并计算结果的应用场景。


大文件插入数据库

1、文件拆分

第一如果程序直接读取这个大文件,假设读取一半的时候,程序突然宕机,这样就会直接丢失文件读取的进度,又需要重新开头读取。

而文件拆分之后,一旦小文件读取结束,我们可以将小文件移动一个指定文件夹。这样即使应用程序宕机重启,我们重新读取时,只需要读取剩余的文件。

第二,一个文件,只能被一个应用程序读取,这样就限制了导入的速度。

而文件拆分之后,我们可以采用多节点部署的方式,水平扩展。每个节点读取一部分文件,这样就可以成倍的加快导入速度。

2、多线程导入

之前拆分的时候,设置每个小文件包含 10w 行的数据。由于担心一下子将 10w 数据读取应用中,导致堆内存占用过高,引起频繁的 Full GC,所以下面采用流式读取的方式,一行一行的读取数据。

上述读取的代码写起来不难,但是存在效率问题,主要是因为只有单线程在导入,上一行数据导入完成之后,才能继续操作下一行。

为了加快导入速度,那我们就多来几个线程,并发导入。

多线程我们自然将会使用线程池的方式

  1. 如果核心线程数未满,将会直接创建线程执行任务。
  2. 如果核心线程数已满,将会把任务放入到队列中。
  3. 如果队列已满,将会再创建线程执行任务。
  4. 如果最大线程数已满,队列也已满,那么将会执行拒绝策略。

由于我们上述线程池设置的核心线程数为 5,很快就到达了最大核心线程数,后续任务只能被加入队列。

为了后续任务不被线程池拒绝,我们可以采用如下方案:

  • 将队列容量设置成很大,包含整个文件所有行数
  • 将最大线程数设置成很大,数量大于件所有行数

第一种是相当于将文件所有内容加载到内存,将会占用过多内存

而第二种创建过多的线程,同样也会占用过多内存

一旦内存占用过多,GC 无法清理,就可能会引起频繁的 Full GC,甚至导致 OOM,导致程序导入速度过慢。

3、扩展线程池

主线程作为生产者不断读取文件,然后将其放置到队列中。

异步线程作为消费者不断从队列中读取内容,导入到数据库中。

一旦队列满载,生产者应该阻塞,直到消费者消费任务。其实我们使用线程池的也是一个生产者-消费者消费模型,其也使用阻塞队列。

一旦线程池满载,主线程将会被阻塞,自定义线程池拒绝策略,当队列满时改为调用 ArrayBlockingQueue.put 来实现生产者的阻塞。 ArrayBlockingQueue的put和take的实现是通过一个ReentrantLock来实现的所以他的put 和 take会阻塞对方

一个超大的文件,我们可以采用拆分文件的方式,将其拆分成多份文件,然后部署多个应用程序提高读取速度。

另外读取过程我们还可以使用多线程的方式并发导入,不过我们需要注意线程池满载之后,将会拒绝后续任务。

我们可以通过扩展线程池,自定义拒绝策略,使读取主线程阻塞。

  • 0
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

德玛西亚!!

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值