问题一:集合你平时用的比较多的有哪些?
Collection 下的 List ArrayList LinkedList
Set集合的话是HashSet
Map 包中的HashMap, 设计到线程安全的话可能会用到ConcurrentHashMap
List 有序 可重复
Set 无序 不可重复 值不相等的元素
问题二:先聊一下ArrayList和LinkedList吧 使用场景
从底层数据结构、线程安全性来描述
ArrayList
1、基于数组实现,查找 访问速度快(基于索引查找) 增删效率低 因为移动元素
2、线程不安全
3、初始容量:创建一个集合时,集合的初始容量为0,在第一次添加元素的时候,会对集合进行扩容,扩容之后,集合容量为10;之后,当向集合中添加元素达到集合的上限时,会对集合再次扩容,扩容为**((旧容量 * 3) / 2) + 1**(JDK1.6) JDK1.7 为 1.5倍。
LinkedList
1、基于带有头尾结点的双向链表实现,提供了头插 LinkedFirst , 尾插是LinkedLast
适合插入删除比较频繁的情况,查询量大的时候比较慢(链表查询 按顺序从第一个开始比较)内部维护了链表的长度
2、线程不安全
问题三:线程安全的情况下,想使用List,怎么用?
Vector 是线程安全的,底层是数组,大部分的方法都是被Synchronized关键字所修饰,线程安全
扩容默认是2倍
问题四:说一下你对HashMap的了解
从底层数据结构、初始化核⼼参数、hash算法、寻址算法、hash冲突、扩容机制、put⽅法,get⽅法和remove⽅法执⾏流程、hashMap是否是线程安全 等角度进行描述
-
底层数据结构
1.7 数组+链表
1.8 数组+链表+红黑树 单链表长度>=8, hash桶 >= 64 会将单链表转换成红黑树存储 如果红黑树结点<=6,重新转成单链表
为什么链表转红黑树的阈值是8?
阈值为8是在时间和空间上权衡的结果
为什么转回链表节点是用的6而不是8?
如果我们设置节点多于8个转红黑树,少于8个就马上转链表,当节点个数在8徘徊时,就会频繁进行红黑树和链表的转换,造成性能的损耗。
-
初始化核心参数
1)capacity:数组容量,默认初始容量是16,HashMap 的容量必须是2的N次方,可以扩容,扩容后数组大小为当前的 2 倍;
2)threshold:扩容阈值,当 HashMap 的个数达到该值,触发扩容。
3)loadFactor:负载因子,默认值是0.75,扩容阈值 = 容量 * 负载因子。
默认情况:初始大小为 16 ,扩容因子 0.75 ,当容量为12的时候,比例已经是0.75 。触发扩容,扩容后的大小为 32
为什么HashMap 的容量必须是 2 的 N 次方?
计算机底层是二进制的,移位和或运算是非常快的,
计算索引位置的公式为:(n - 1) & hash,当 n 为 2 的 N 次方时,n - 1 为低位全是 1 的值,如下图第一行,此时任何值跟 n - 1 进行 & 运算会等于其本身,达到了和取模同样的效果,实现了均匀分布。实际上,这个设计就是基于公式:x mod 2^n = x & (2^n - 1),因为 & 运算比 mod 具有更高的效率。如下图,当 n 不为 2 的 N 次方时,hash 冲突的概率明显增大。
为什么负载因子是0.75 ?时间和空间上权衡的结果。如果值较高,例如1,此时会减少空间开销,但是 hash 冲突的概率会增大,增加查找成本;
而如果值较低,例如 0.5 ,此时 hash 冲突会降低,但是有一半的空间会被浪费,所以折衷考虑 0.75 似乎是一个合理的值。
-
hash算法
拿到 key 的 hashCode,并将 hashCode 的高16位和 hashCode 进行异或(XOR)运算,得到最终的 hash 值。
static final int hash(Object key) { int h; return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16); }
为什么要将 hashCode 的高16位参与运算?
例如下图,如果不加入高位运算,由于 n - 1 是 0000 0111,所以结果只取决于 hash 值的低3位,无论高位怎么变化,结果都是一样的。
如果我们将高位参与运算,则索引计算结果就不会仅取决于低位。
-
寻址算法
(n-1)& hash
-
hash冲突
不同的关键字经过哈希函数运算之后可能获得相同的hash值,就发生了Hash冲突
1)开放定址法:当关键字key的哈希地址p=H(key)出现冲突时,以p为基础,产生另一个哈希地址p1,如果p1仍然冲突,再以p为基础,产生另一个哈希地址p2,…,直到找出一个不冲突的哈希地址pi ,将相应元素存入其中。
线性探测再散列
二次探测再散列
伪随机探测再散列
2)再哈希法:构造多个不同的哈希函数,当哈希地址Hi=RH1(key)发生冲突时,再计算Hi=RH2(key)……,直到冲突不再产生。
3)链地址法:所有哈希地址为i的元素构成一个单链表
4)建立公共溢出区:将哈希表分为基本表和溢出表两部分,将所有发生哈希冲突的记录都存储到溢出表。
-
扩容机制
扩容之后如何定位到新表的位置呢?红黑树和链表都是通过 e.hash & oldCap == 0 来定位在新表的索引位置
为什么用e.hash & oldCap == 0 来定位?
扩容前 table 的容量为16,a 节点和 b 节点在扩容前处于同一索引位置。
扩容后,table 长度为32,新表的 n - 1 只比老表的 n - 1 在高位多了一个1(图中标红)。
因为 2 个节点在老表是同一个索引位置,因此计算新表的索引位置时,只取决于新表在高位多出来的这一位(图中标红),而这一位的值刚好等于 oldCap。因为只取决于这一位,所以只会存在两种情况:
1) (e.hash & oldCap) == 0 ,则新表索引位置为“原索引位置” ;
2)(e.hash & oldCap) == 1,则新表索引位置为“原索引 + oldCap 位置”。
-
put方法,get方法和remove方法执行流程
put方法如下图
-
hashMap是否是线程安全
线程不安全,HashMap 在并发下存在数据覆盖、遍历的同时进行修改会抛出 ConcurrentModificationException 异常等问题,
JDK 1.8 之前还存在死循环问题。导致死循环的根本原因是 JDK 1.7 扩容采用的是“头插法”,会导致同一索引位置的节点在扩容后顺序 反掉。而 JDK 1.8 之后采用的是“尾插法”,扩容后节点顺序不会反掉,不存在死循环问题。
1.7在put的时候有resize的一个过程,这个过程头插会形成环形链表导致一直死循环,导致CPU利用率接近100%
-
JDK 1.8 的主要优化哪些地方
1)底层数据结构从“数组+链表”改成“数组+链表+红黑树”,主要是优化了 hash 冲突较严重时,链表过长的查找性能:O(n) -> O(logn)。
2)计算 table 初始容量的方式发生了改变,老的方式是从1开始不断向左进行移位运算,直到找到大于等于入参容量的值;新的方式则是通过“5个移位+或等于运算”来计算。
3)优化了 hash 值的计算方式,新的只是简单的让高16位参与了运算。
4)扩容时插入方式从**“头插法”改成“尾插法”**,避免了并发下的死循环。
5)扩容时计算节点在新表的索引位置方式从“h & (length-1)”改成“hash & oldCap”,性能可能提升不大,但设计更巧妙、更优雅。
问题五:HashMap线程不安全,如何处理保证线程安全?
使用ConcurrentHashMap 保证线程安全
**回答思路:**底层数据结构、hash算法、寻址算法、put⽅法流程,get⽅法,remove⽅法,size⽅法、jdk 1.7和1.8底层实现区别,1.7如何去锁segment
分段锁:整个 ConcurrentHashMap 由一个个 Segment 组成,
1.7 为了保证线程安全,采用分段锁,Segment 继承于 ReetrantLock ,
1.8 数组+单链表+红黑树的数据结构 1.8之后逐渐放弃分段锁机制,而是采用Synchronized+CAS 机制来实现
1.7 的底层数据结构
ConcurrentHashMap是由Segment数组结构和HashEntry数组结构组成。Segment是一种可重入锁ReentrantLock,在ConcurrentHashMap里扮演锁的角色,HashEntry则用于存储键值对数据。 一个Segment里包含一个HashEntry数组,每个HashEntry是一个链表结构的元素, 每个Segment守护者一个HashEntry数组里的元素,当对HashEntry数组的数据进行修改时,必须首先获得它对应的Segment锁。
ConcurrentHashMap实现技术是保证HashEntry几乎是不可变的。HashEntry代表每个hash链中的一个节点,其结构如下所示:
static final class HashEntry<K,V> {
final K key;
final int hash;
volatile V value;
final HashEntry next;
}
可以看到除了value不是final的,其它值都是final的,这意味着不能从hash链的中间或尾部添加或删除节点,因为这需要修改next 引用值,所有的节点的修改只能从头部开始。对于put操作,可以一律添加到Hash链的头部。
但是对于remove操作,可能需要从中间删除一个节点,这就需要将要删除节点的前面所有节点整个复制一遍,最后一个节点指向要删除结点的下一个结点。这在讲解删除操作时还会详述。为了确保读操作能够看到最新的值,将value设置成volatile,这避免了加锁。
问:1.7分段锁 如果要找到某个具体的值,需要经过几次哈希?怎么去找到具体数值的呢?
在读取和写入的时候需要需要做两次哈希,但这两次哈希换来的是更细力粒度的锁,也就意味着可以支持更高的并发
那么在插入和获取元素的时候,必须先通过哈希算法定位到Segment。可以看到ConcurrentHashMap会首先使用Wang/Jenkins hash的变种算法对元素的hashCode进行一次再哈希。再哈希,其目的是为了减少哈希冲突,使元素能够均匀的分布在不同的Segment上,从而提高容器的存取效率。假如哈希的质量差到极点,那么所有的元素都在一个Segment中,不仅存取元素缓慢,分段锁也会失去意义。
(1)确定元素在segment
数组中的位置
以put
方法为例,假如segment
数组的大小为2的n次方,则hash >>> segmentShift
正好取了key的哈希值的高n位,再与掩码segmentMask
相与相当与仍然用key的哈希的高位来确定数据项在segment
数组中的位置。
(2)跟HashMap类似 ,找到HashEntry 数值中的位置
put操作
从上Segment的继承体系可以看出,Segment实现了ReentrantLock,也就带有锁的功能,当执行put操作时,会进行第一次key的hash来定位Segment的位置,如果该Segment还没有初始化,即通过CAS操作进行赋值,然后进行第二次hash操作,找到相应的HashEntry的位置,这里会利用继承过来的锁的特性,在将数据插入指定的HashEntry位置时(链表的尾端),会通过继承ReentrantLock的tryLock()方法尝试去获取锁,如果获取成功就直接插入相应的位置,如果已经有线程获取该Segment的锁,那当前线程会以自旋的方式去继续的调用tryLock()方法去获取锁,超过指定次数就挂起,等待唤醒。
get操作
ConcurrentHashMap的get操作跟HashMap类似,只是ConcurrentHashMap第一次需要经过一次hash定位到Segment的位置,然后再hash定位到指定的HashEntry,遍历该HashEntry下的链表进行对比,成功就返回,不成功就返回null。
size操作
第一种方案他会使用不加锁的模式去尝试多次计算ConcurrentHashMap的size,最多三次,比较前后两次计算的结果,结果一致就认为当前没有元素加入,计算的结果是准确的;
第二种方案是如果第一种方案不符合,他就会给每个Segment加上锁,然后计算ConcurrentHashMap的size返回。
扩容
- **是否需要扩容。**在插入元素前会先判断Segment里的HashEntry数组是否超过容量(threshold),如果超过阀值,数组进行扩容。值得一提的是,Segment的扩容判断比HashMap更恰当,因为HashMap是在插入元素后判断元素是否已经到达容量的,如果到达了就进行扩容,但是很有可能扩容之后没有新元素插入,这时HashMap就进行了一次无效的扩容。
- **如何扩容。**扩容的时候首先会创建一个两倍于原容量的数组,然后将原数组里的元素进行再hash后插入到新的数组里。为了高效ConcurrentHashMap不会对整个容器进行扩容,而只对某个segment进行扩容。
问:1.7 分段锁 在高并发下的情况下如何保证取得的元素是最新的?
用于存储键值对数据的HashEntry,在设计上它的成员变量value等都是volatile类型的,这样就保证别的线程对value值的修改,get方法可以马上看到。
问:1.8 底层数据结构 如何进行插入、获取元素、删除元素?
1.8 数组+单链表+红黑树的数据结构 1.8之后逐渐放弃分段锁机制,而是采用Synchronized+CAS 机制来实现
这个put的过程很清晰,对当前的table进行无条件自循环直到put成功,可以分成以下六步流程来概述
- 如果没有初始化就先调用initTable()方法来进行初始化过程
- 如果没有hash冲突就直接CAS插入
- 如果还在进行扩容操作就先进行扩容
- 如果存在hash冲突,就加锁来保证线程安全,这里有两种情况,一种是链表形式就直接遍历到尾端插入,一种是红黑树就按照红黑树结构插入,
- 最后一个如果该链表的数量大于阈值8,就要先转换成黑红树的结构,break再一次进入循环
- 如果添加成功就调用addCount()方法统计size,并且检查是否需要扩容
ConcurrentHashMap 1.8 的get操作的流程很简单,也很清晰,可以分为三个步骤来描述
- 计算hash值,定位到该table索引位置,如果是首节点符合就返回
- 如果遇到扩容的时候,会调用标志正在扩容节点ForwardingNode的find方法,查找该节点,匹配就返回
- 以上都不符合的话,就往下遍历节点,匹配就返回,否则最后就返回null
问:ConcurrentHashMap 1.8 做了哪些修改?
- JDK1.8取消了segment数组,直接用table保存数据,锁的粒度更小,减少并发冲突的概率。
- JDK1.8存储数据时采用了链表+红黑树的形式,纯链表的形式时间复杂度为O(n),红黑树则为O(logn),性能提升很大。什么时候链表转红黑树?当key值相等的元素形成的链表中元素个数超过8个的时候。
- JDK1.8的实现降低锁的粒度,JDK1.7版本锁的粒度是基于Segment的,包含多个HashEntry,而JDK1.8锁的粒度就是HashEntry(首节点)
- JDK1.8版本的数据结构变得更加简单,使得操作也更加清晰流畅,因为已经使用synchronized来进行同步,所以不需要分段锁的概念,也就不需要Segment这种数据结构了,由于粒度的降低,实现的复杂度也增加了
- JDK1.8使用红黑树来优化链表,基于长度很长的链表的遍历是一个很漫长的过程,而红黑树的遍历效率是很快的,代替一定阈值的链表,这样形成一个最佳拍档
- JDK1.8为什么使用内置锁synchronized来代替重入锁ReentrantLock,有以下几点:
- 因为粒度降低了,在相对而言的低粒度加锁方式,synchronized并不比ReentrantLock差,在粗粒度加锁中ReentrantLock可能通过Condition来控制各个低粒度的边界,更加的灵活,而在低粒度中,Condition的优势就没有了
- JVM1.6 对synchronized 锁进行了升级
- 在大量的数据操作下,基于API的ReentrantLock会开销更多的内存
问:什么是CAS ? 实际的应用?存在什么问题?
CAS 存在的问题:(1) 忙循环cpu开销大;(2) 只能保证一个共享变量原子操作(AtomicReference)(3) ABA 标志位 时间戳
实际应用:并发包 并发修改 底层源码
CAS 比较 交换 轻量级加锁的过程 锁竞争不激烈 低并发性能好一些
缺点:ABA 问题 两次读之间可能被其他线程修改过,这个问题可以通过添加一个时间戳或者标志位来解决, 加版本号、标志
为什么不用HashTable或Collection.Synchronized 来替代,为什么选择了ConcurrentHashMap
HashTable容器使用synchronized来保证线程安全,访问HashTable的线程都必须竞争同一把锁,线程竞争激烈的情况下HashTable的效率非常低下, 因为当一个线程访问HashTable的同步方法时,其他线程访问HashTable的同步方法时,可能会进入阻塞或轮询状态。如线程1使用put进行添加元素,线程2不但不能使用put方法添加元素,并且也不能使用get方法来获取元素,所以竞争越激烈效率越低。
ConcurrentHashMap1.7 , 容器里有多把锁,每一把锁用于锁容器其中一部分数据,那么当多线程访问容器里不同数据段的数据时,线程间就不会存在锁竞争,从而可以有效的提高并发访问效率, 将数据分成一段一段的存储,然后给每一段数据配一把锁,当一个线程占用锁访问其中一个段数据的时候,其他段的数据也能被其他线程访问。
size()和containsValue(),它们可能需要锁定整个表而而不仅仅是某个段,这需要按顺序锁定所有段,操作完毕后,又按顺序释放所有段的锁。
ConcurrentHashMap 并发度更高
问题六:说一下Synchronized
回答思路:使用场景、保证特性(原子性、可见性、有序性、可重入性)、锁升级、底层的实现⽅式、与Lock的比较
- 使用场景
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-leLTSe8I-1644506234806)(C:\Users\baoqi\Desktop\image-20220210124303936.png)]
两个对象同时对一个对象的同步方法进行操作,只有一个线程能抢到锁,只有一个线程能够抢到锁,因为一个对象只有一把锁,一个线程获取了该对象的锁之后,其他线程无法获取该对象的锁,即其他线程不能访问该对象的其他synchronized 实例方法,但是可以访问该对象的非synchronized 修饰方法。
Synchronized不论是修饰方法还是代码块,都是通过持有修饰对象的锁来实现同步,那么
Synchronized锁对象是存在哪里的呢?—— 是存在锁对象的对象头的MarkWord中。
那么MarkWord在对象头中到底长什么样,也就是它到底存储了什么呢?
在32位的虚拟机中:
-
保证特性(原子性、可见性、有序性、可重入性)
-
锁升级以及加锁过程
锁的状态:无锁状态、偏向锁状态、轻量级锁状态、重量级锁状态(级别从低到高)
当线程1访问代码块并获取锁对象时,会在java对象头和栈帧中记录偏向的锁的threadID,因为偏向锁不会主动释放锁,因此以后线程1再次获取锁的时候,需要比较当前线程的threadID和Java对象头中的threadID是否一致,如果一致(还是线程1获取锁对象),则无需使用CAS来加锁、解锁;如果不一致(其他线程,如线程2要竞争锁对象,而偏向锁不会主动释放因此还是存储的线程1的threadID),那么需要查看Java对象头中记录的线程1是否存活,如果没有存活,那么锁对象被重置为无锁状态,其它线程(线程2)可以竞争将其设置为偏向锁;如果存活,那么立刻查找该线程(线程1)的栈帧信息,如果还是需要继续持有这个锁对象,那么暂停当前线程1,撤销偏向锁,升级为轻量级锁,在虚拟机栈中开辟一个空间 Lock Record, 将锁对象的Mark Word 写入,再尝试将Lock Record 的指针 使用CAS去修改锁对象头区域,如果线程1 不再使用该锁对象,那么将锁对象状态设为无锁状态,重新偏向新的线程。
重量级锁,互斥锁,加锁过程:同步代码块,在编译之后,会在代码块前后加上两个指令monitorenter、monitorexit,一个线程来时,它发现这个对象图中,锁标志位是01,锁对象会跟监视器锁monitor对象进行关联,它会在monitor的一个锁定期加1,并且将monitor 对象的指针写入到对象头中,并修改锁对象标志位为10,而且是可重入的,再次获取锁不需要重复加锁释放锁,如果再来一个线程,他会检查这个锁对象头,monitor监视器上计数器不为0,它会在monitor 监视状态下去竞争这个锁,如果操作结束了,退出释放锁,并且逐步将加上的锁释放几次,将计数器清零,完成对锁的释放,其他线程去竞争这个锁。
锁升级的过程,大量的锁升级成重量级锁,但是过了高峰,但是回不去了,QPS 稳定 很高的情况下使用?
为什么要引入偏向锁?
—— 大多数时候是不存在锁竞争的,常常是一个线程多次获得同一个锁,因此如果每次都要竞争锁会增大很多没有必要付出的代价
为什么要引入轻量级锁?
——竞争锁对象的线程不多,而且线程持有锁的时间也不长,线程需要CPU从用户态转到内核态代价较大,可以自旋这等待锁释放。
为什么要引入重量级锁?
——自旋的时间太长也不行,因为自旋是要消耗CPU的,自旋的次数是有限制的,比如10次或者100次,如果自旋次数到了线程1还没有释放锁,或者线程1还在执行,线程2还在自旋等待,这时又有一个线程3过来竞争这个锁对象,那么这个时候轻量级锁就会膨胀为重量级锁。重量级锁把除了拥有锁的线程都阻塞,防止CPU空转。
- 底层的实现方式
1、当多个线程同时访问该方法,那么这些线程会先被放进EntryList队列,此时线程处于blocking状态
2、当一个线程获取到了实例对象的监视器(monitor)锁,那么就可以进入running状态,执行方法,此时,ObjectMonitor对象的owner指向当前线程,count加1表示当前对象锁被一个线程获取
3、当running状态线程调用wait()方法,那么当前线程释放monitor对象,进入waiting状态,ObjectMonitor对象的owner变为null,count减1,同时线程进入WaitSet队列,直到有线程调用notify()方法唤醒该线程,则该线程重新获取monitor对象进入Owner区
4、如果当前线程执行完毕,那么也释放monitor对象,进入waiting状态,ObjectMonitor对象的owner变为null,_count减1
那么Synchronized修饰的代码块如何获取monitor对象的呢?
Synchronized代码块同步在需要同步的代码块开始的位置插入monitorenter指令,在同步结束的位置或者异常出现的位置插入monitorexit指令;JVM要保证monitorentry和monitorexit都是成对出现的,任何对象都有一个monitor与之对应,当这个对象的monitor被持有以后,它将处于锁定状态。
那么Synchronized修饰的方法如何获取monitor对象的呢?
Synchronized方法同步不再是通过插入monitorentry和monitorexit指令实现,而是由方法调用指令来读取运行时常量池中的ACC_SYNCHRONIZED标志隐式实现的,如果方法表结构(method_info Structure)中的ACC_SYNCHRONIZED标志被设置,那么线程在执行方法前会先去获取对象的monitor对象,如果获取成功则执行方法代码,执行完毕后释放monitor对象,如果monitor对象已经被其它线程获取,那么当前线程被阻塞。
- 与Lock的比较
Synchronized 是JVM 关键字;ReentrantLock类手动编码 手动lock,配合try finally 代码块手动释放锁
ReentrantLock 高级特性:
1、ReentrantLock如何避免死锁:响应中断、可轮询锁、定时锁
(1)如果有一个线程长期等待不到一个锁,为了防止死锁,可以手动调用lockInterruptibly () 方法 释放自己的资源不去等待
(2)可轮询锁:通过boolean tryLock()获取锁。如果有可用锁,则获取该锁并返回true,如果无可用锁,则立即返回false
(3)定时锁:通过boolean tryLocklong time,TimeUnit unit)throws InterruptedException获取定时锁。如果在给定的时间内获取到了可用锁,且当前线程未被中断,则获取该锁
并返回true。如果在给定的时间内获取不到可用锁,将禁用当前线程,并且在发生以下三种情况之前,该线程一直处于休眠状态。
2、提供了公平锁的方式,ReentrantLock(boolean fair)中传递不同的参数创建公平锁,默认是非公平锁,不推荐使用,使得ReentrantLock 性能下降
3、对外提供了Condition ,可以指定唤醒绑定到Condition身上的线程,实现选择性通知的机制。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-Wr75ScDP-1644506234806)(C:\Users\baoqi\AppData\Roaming\Typora\typora-user-images\image-20220210144856541.png)]
问题六:JUC 下还用过哪些包?
CountDownLatch、CyclicBarrier 、Semaphore
CountDownLatch 适合于单线程在运行时,有一段过程又希望并发执行,后又回归到一个单线程状态,它适合一个线程去等待一批线程到达一个同步点,之后再继续进行
计数器时不能重用的
CyclicBarrier 类似,它是一批线程同时到达一个临界点,之后再往下走,计数器可以留下来的
CountDownLatch 是如何实现计数功能的?
高并发,对数据加减,会存在并发竞争,AQS 里面有个标志位,用volatile修饰
问:volatile有了解吗?
回答思路:MESI、Java内存模型JMM、可见性有序性、跳出死循环、AtomicInteger
volatile 是JVM 提供的最轻量级的一个关键字,CPU和内存之间的线程效率是差好多数量级的,但为了保证他们之间的计算,不影响CPU的计算,中间有好多像LLV 缓存,我们线程再这个缓存中去工作,首先它会取数据从主内存到工作内存,工作内存中计算,计算完之后传回去,这个时候就有问题,
多个线程之间的可见性是如何保证的?
计算机层面的话有好多协议,JVM 为了解决这些比较复杂的东西,提供了JMM模型,volatile 去修饰一个变量,保证这个变量在线程之间的可见性,修改这个变量之后,立刻刷到主内存,它在使用时会立刻从主内存中取出来刷新的那个值,volatile 是不能保证原子性的,像自增自减这种操作是不能保证数据安全的
那原子性怎么保证呢?
原子性可以使用Synchronized Reentrantlock 通过加锁来保证原子性 AtomicInteger
hashMap 知识转载自:https://mp.weixin.qq.com/s/wIjAj4rAAZccAl-yhmj_TA
Synchronized 知识转载自:https://blog.csdn.net/tongdanping/article/details/79647337