我们知道,ConcurrentHashMap在使用时,和HashMap有一个比较大的区别,那就是HashMap中,null可以作为键或者值都可以。而在ConcurrentHashMap中,key和value都不允许为null。
那么,为什么呢?为啥ConcurrentHashMap要设计成这样的呢?
关于这个问题,其实最有发言权的就是ConcurrentHashMap的作者——Doug Lea。
他自己曾经出面解释过这个问题,内容如下(原文地址已经打不开了,大家将就着看一下截图吧) :
主要意思就是说:
ConcurrentMap(如ConcurrentHashMap、ConcurrentSkipListMap)不允许使用null值的主要原因是,在非并发的Map中(如HashMap),是可以容忍模糊性(二义性)的,而在并发Map中是无法容忍的。
假如说,所有的Map都支持null的话,那么map.get(key)就可以返回null,但是,这时候就会存在一个不确定性,当你拿到null的时候,你是不知道他是因为本来就存了一个null进去还是说就是因为没找到而返回了null。
在HashMap中,因为它的设计就是给单线程用的,所以当我们map.get(key)返回null的时候,我们是可以通过map.contains(key)检查来进行检测的,如果它返回true,则认为是存了一个null,否则就是因为没找到而返回了null
。
但是,像ConcurrentHashMap,它是为并发而生的,它是要用在并发场景中
的,当我们map.get(key)返回null的时候,是没办法通过通过map.contains(key)检查来准确的检测,因为在检测过程中可能会被其他线程锁修改,而导致检测结果并不可靠
。
所以,为了让ConcurrentHashMap的语义更加准确,不存在二义性的问题,他就不支持null
。
数组
、单向链表
、红黑树
组成。链式寻址法
解决 hash 冲突。 O(n)
。因此在 JDK1.8 中,引入了红黑树的机制。通过对指定的 Node 节点加锁
,来保证数据更新的安全性O(logn)
;ConcurrentHashMap 引入了多线程并发扩容的机制
,简单来说就是多个线程对原始数组进行分片后,每个线程负责一个分片的数据迁移,从而提升了扩容过程中数据迁移的效率。首先,我们来看JDK 1.7 中ConcurrentHashMap 的底层结构,它基本延续了HashMap的设计,采用的是数组 加 链表的形式。和 HashMap 不同的是,ConcurrentHashMap中的数组设计 分为大数组Segment
和小数组HashEntry
,来着这张图。
大树组Segment 可以理解为一个数据
库,而每个数据库(Segment)中又有很多张表(HashEntry)
,每个 HashEntry 中又有很多条数据,这些数据是用链表连接的
。了解了ConcurrentHashMap 的基本结构设计,我们再来看它的线程安全实现,就比较简单了。
因为Segment 本身是基于ReentrantLock 重入锁实现的加锁和释放锁的操作,这样就能保证多个线程同时访问ConcurrentHashMap 时,同一时间只能有一个线程能够操作相应的节点,这样就保证了 ConcurrentHashMap 的线程安全
就是说ConcurrentHashMap 的线程安全是建立在Segment 加锁的基础上的,所以,我们称它为分段锁或者片段锁
在JDK1.7 中,ConcurrentHashMap 虽然是线程安全的,但因为它的底层实现是数组加链表的形式,所以在数据比较多情况下,因为要遍历整个链表,会降低访问性能。所以,JDK1.8 以后采用了数组 加 链表 加 红黑树的方式优化了 ConcurrentHashMap的实现,具体实现如图所示。
当链表长度大于 8,并且数组长度大于 64 时,链表就会升级为红黑树的结构。JDK 1.8中的ConcurrentHashMap 虽然保留了Segment 的定义,但这,仅仅是为了保证序列化时的兼容性,不再有任何结构上的用处了。
那在JDK 1.8 中ConcurrentHashMap 的源码是如何实现的呢?它主要是使用了 CAS加 volatile 或者 synchronized 的方式来保证线程安全。
我们可以从源码片段中看到,添加元素时首先会判断容器是否为空,
如果为空则使用 volatile 加 CAS 来初始化,
如果容器不为空,则根据存储的元素计算该位置是否为空。
如果根据存储的元素计算结果为空则利用 CAS 设置该节点;
如果根据存储的元素计算为空不为空,则使用 synchronized ,然后,遍历桶中的数据,并替换或新增节点到桶中,
最后再判断是否需要转为红黑树。这样就能保证并发访问时的线程安全了。
如果把上面的执行用一句话归纳的话,就相当于是 ConcurrentHashMap 通过对头结点加锁来保证线程安全的。
这样设计的好处是,使得锁的粒度相比Segment 来说更小了,发生 hash 冲突 和 加锁的频率也降低了,在并发场景下的操作性能也提高了。而且,当数据量比较大的时候,查询性能也得到了很大的提升。
ConcurrentHashMap 在JDK 1.7 中使用的数组 加 链表的结构,其中数组分为两类,大树组Segment 和 小数组 HashEntry,而加锁是通过给Segment 添加 ReentrantLock 重入锁
来保证线程安全的。
ConcurrentHashMap 在JDK1.8 中使用的是数组 加 链表 加 红黑树的方式实现,它是通过 CAS
或者 synchronized
来保证线程安全的,并且缩小了锁的粒度,查询 性能也更高。
ConcurrentHashMap 的size()方法是非线程安全的
也就是说,当有线程调用put 方法在添加元素的时候,其他线程在调用size()方法获取的元素个数和实际存储元素个数是不一致的。
原因是size()方法是一个非同步方法,put()方法和 size()方法并没有实现同步锁。
所以很明显,size()方法得到的数据和真实数据必然是不一致的。
因此从size()方法本身来看,它的整个计算过程是线程安全的,因为这里用到了CAS的方式解决了并发更新问题。但是站在ConcurrentHashMap 全局角度来看,put()方法和size()方法之间的数据是不一致的,因此也就不是线程安全的。
之所以不像HashTable 那样,直接在方法级别加同步锁。在我看来有两个考虑点。
Java中的volatile关键字是通过调用C语言实现的,而在更底层的实现上,即汇编语言的层面上,用volatile关键字修饰后的变量在操作时,最终解析的汇编指令会在指令前加上lock前缀指令来保证工作内存中读取到的数据是主内存中最新的数据。
具体的实现原理是在硬件层面上通过:
MESI缓存一致性协议
:多个cpu从主内存读取数据到高速缓存中,如果其中一个cpu修改了数据
,会通过总线立即回写到主内存中,其他cpu会通过总线嗅探机制
感知到缓存中数据的变化并将工作内存中的数据失效,再去读取主内存中的数据。IA32架构软件开发者手册对lock前缀指令的解释:
有序性
主要通过对volatile修饰的变量的读写操作前后加上各种特定的内存屏障来禁止指令重排序来保障有序性的。
可见性
主要是通过 Lock前缀指令 + MESI缓存一致性协议来实现的
。对volatiile修饰的变量执行写操作时,JVM会发送一个Lock前缀指令给CPU,CPU在执行完写操作后,会立即将新值刷新到内存,同时因为MESI缓存一致性协议,其他各个CPU都会对总线嗅探,看自己本地缓存中的数据是否被别人修改,如果发现修改了,会把自己本地缓存的数据过期掉。然后这个CPU里的线程在读取改变量时,就会从主内存里加载最新的值了,这样就保证了可见性。
总结:
volatile 关键字有两个作用。
这个可见性问题,我认为本质上是由几个方面造成的:
所以,对于增加了 volatile 关键字修饰的共享变量,JVM 虚拟机会自动增加一个#Lock汇编指令,这个指令会根据 CPU 型号自动添加总线锁
或缓存锁
我简单说一下这两种锁,
总线锁是锁定了 CPU 的前端总线
,从而导致在同一时刻只能有一个线程去和内存通信
,这样就避免了多线程并发造成的可见性。缓存锁
是对总线锁的优化,因为总线锁导致了CPU 的使用效率大幅度下降,所以缓存锁只针对CPU 三级缓存中的目标数据加锁,缓存锁是使用 MESI 缓存一致性来实现
的指令重排序,所谓重排序,就是指令的编写顺序和执行顺序不一致,在多线程环境下导致可见性问题。
指令重排序本质上是一种性能优化的手段,它来自于几个方面:
内存屏障指令
,上层应用可以在合适的地方插入内存屏障来避免CPU 指令重排序问题。所以,如果对共享变量增加了 volatile 关键字,那么在编译器层面,就不会去触发编译器优化,同时再 JVM 里面,会插入内存屏障指令来避免重排序问题。当然,除了volatile 以外,从JDK5 开始,JMM 就使用了一种Happens-Before 模型
描述多线程之间的内存可见性问题。
如果两个操作之间具备 Happens-Before 关系,那么意味着这两个操作具备可见性关系,不需要再额外去考虑增加 volatile 关键字来提供可见性保障。
JMM(java内存模型java Memory Model),本身是一种抽象的概念并不真实存在,它的描述是一组规则或规范,通过这组规范定义了程序中各个变量(包括实列字段,静态字段和构成数组对象的元素)的访问方式
1.线程解锁前,必须把共享变量的值刷回主内存
2.线程加锁前,必须读取主内存中最新值到自己的工作内存
3.加锁解锁是同一把锁
由于JVM运行程序实体是线程,而每个线程创建时JVM都会为其创建一个工作内存(些地方称为栈空间),工作内存是每个线程的私空间,而java内存模型中规定所的变量都存储在主内存,主内存是共享区域,所线程都可以访问,但线程对变量的操作(读赋值等)都必须在工作内存中进行,首先要将变量从主内存拷贝到自己的工作内存,然后对变量副本拷贝,因此不同的线程间无法访问对方的工作内存,
线程间的通信(传值)必须通过主内存来完成
。
JMM要求保证 可见性,原子性,有序性(禁止指令重排)
并不是所都遵循JMM
1.可见性
通过前面对JMM的介绍,我们知道各个线程对主内存中共享变量的操作都是各个线程各自拷贝到自己的工作内存操作后再写回主内存中的.
这就可能存在一个线程AAA修改了共享变量X的值还未写回主内存中时 ,另外一个线程BBB又对内存中的一个共享变量X进行操作,但此时A线程工作内存中的共享比那里X对线程B来说并不不可见.这种工作内存与主内存同步延迟现象就造成了可见性问题.
2.原子性
3.有序性
计算机在执行程序时,为了提高性能,编译器和处理器常常会做指令重排,一把分为以下3中
单线程环境里面确保程序最终执行结果和代码顺序执行的结果一致.
处理器在进行重新排序是必须要考虑指令之间的数据依赖性
多线程环境中线程交替执行,由于编译器优化重排的存在,两个线程使用的变量能否保持一致性是无法确定的,结果无法预测
首先,计算机工程师为了提高 CPU 的利用率,平衡CPU 和内存之间的速度差异,在 CPU 里面设计了三级缓存
。
CPU 在向内存发起IO 操作的时候,一次性会读取 64 个字节的数据作为一个缓存行,缓存到CPU 的高速缓存里面。
在Java 中一个long 类型是 8 个字节,意味着一个缓存行可以存储 8 个long 类型的变量。
这个设计是基于空间局部性原理
来实现的,也就是说,如果一个存储器的位置被引用,那么将来它附近的位置也会被引用。
所以缓存行
的设计对于CPU 来说,可以有效的减少和内存的交互次数,从而避免了 CPU的IO 等待,以提升CPU 的利用率。
正是因为这种缓存行的设计,导致如果多个线程修改同一个缓存行里面的多个独立变量的时候,基于缓存一致性协议,就会无意中影响了彼此的性能
,这就是伪共享
的问题。
(如图)像这样一种情况,CPU0 上运行的线程想要更新变量 X、CPU1 上的线程想要更新变量Y,而X/Y/Z 都在同一个缓存行里面。每个线程都需要去竞争缓存行的所有权对变量做更新,基于缓存一致性协议。
一旦运行在某个 CPU 上的线程获得了所有权并执行了修改,就会导致其他 CPU 中的缓存行失效。这就是伪共享问题的原理。
因为伪共享会问题导致缓存锁的竞争,所以在并发场景中的程序执行效率一定会收到较大的影响;
这个问题的解决办法有两个:
使用对齐填充
,因为一个缓存行大小是 64 个字节,如果读取的目标数据小于 64个字节,可以增加一些无意义的成员变量来填充。@Contented 注解
,它也是通过缓存行填充来解决伪共享问题的,被@Contented 注解声明的类或者字段,会被加载到独立的缓存行上。在Netty 里面,有大量用到对齐填充
的方式来避免伪共享问题。
Vector 所有方法都加锁
public synchronized boolean add(E e) {
modCount++;
ensureCapacityHelper(elementCount + 1);
elementData[elementCount++] = e;
return true;
}
public synchronized E get(int index) {
if (index >= elementCount)
throw new ArrayIndexOutOfBoundsException(index);
return elementData(index);
}
不推荐,Vector 太老 1.0就出现了 arrayList出现是1.2 1.Vector使用同步方法实现,Synchronized同步方法实现 太重
Collections.synchronizedList(ArrayList);
public boolean add(T e) {
synchronized(mutex) {
return backingList.add(e);
}
}
public E get(int index) {
synchronized (mutex) {return list.get(index);}
同样不推荐,synchronizedList使用同步代码块实现
在SynchronizedMap内部维护了一个普通的对象Map,还有排斥锁mutex,调用该方法时就需要传入一个map.有两个构造器
,如果你也传入了mutex,则将mutex参数赋值给传入的对象,如果没有则为this,则调用synchronized对象,创建完
SynchronizedMap之后,就会大多数方法里面使用同步代码块上锁
public boolean add(E e) {
final ReentrantLock lock = this.lock;
lock.lock();
try {
Object[] elements = getArray();
int len = elements.length;
Object[] newElements = Arrays.copyOf(elements, len + 1);
newElements[len] = e;
setArray(newElements);
return true;
} finally {
lock.unlock();
}
}
public E get(int index) {
return get(getArray(), index);
}
推荐
选择使用CopyOnWriteArrayList 写时复制,CopyOnWriteArrayList容器即写时复制的容器,往一个容器当中添加元素的时候,不直接往当前容器的Object[]添加,而是先将当前容器Copy,复制出一份新的容器 object[] newelements,往新的容器中添加元素,添加完,再将原来的容器指向新的容器,这样的好处是并发的读,,读操作不需要加锁,这是一种读写分离思想
可重入锁(也叫做递归锁)指的是同一线程外层函数获得锁之后,内层递归函数仍然能获取该锁的代码,在同一个线程在外层方法获取锁的时候,在进入内层方法会自动获取锁也即是说,线程可以进入任何一个它已经拥有的锁所同步着的代码块。
ReentrantLock/Synchronized就是经典的可重入锁,可重入锁最大的作用就是避免死锁
代码举列:
public sync void method(){
method2();
}
public sync void method2(){}
指的是同一个线程外层函数获得锁之后,内层递归函数仍然可以获取该锁的代码
看下面这串代码:这样写法对不对,编译通过不,运行会不会报错
public void get(){
lock.lock();
locak.lock();
try{}finally{
lock.unlock();
lock.unlock();
}
}
答案是: 要配对,写几个lock就要几个unlock,多写几对程序编译通过,运行也不会报错,正常
但是如果写2个lock但是只一个unlock,编译通过,运行也没问题,但是程序会一直卡着运行不结束;
Java ReentrantLock而言, 通过构造函数指定该锁是否是公平锁 默认是非公平锁 非公平锁的优点在于吞吐量比公平锁大.
对于synchronized而言 也是一种非公平锁.
是指尝试获取锁的线程不会立即阻塞,而是采肛循环的方式去尝试获取锁,这样的好处是减少线程上下文切换的消耗,缺点是循环会消耗CPU
自旋锁demo
import java.security.PublicKey;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicReference;
/**
* @version V1.0.0
* @author: WangQingLong
* @date: 2020/8/1 9:05
* @description: 自旋锁 使用循环代替阻塞 优点:减少上下文切换消耗 缺点:CPU蹦高,
*/
public class SpinDemo {
//原子引用线程
//如果数据类型,不赋值默认为0 如果是引用类型,默认为null
AtomicReference<Thread> a = new AtomicReference<>();
public void myLock(){
Thread thread = Thread.currentThread();
System.out.println("Mylock当前线程为"+thread.getName()+"进来了");
while (!a.compareAndSet(null,thread)){
}
System.out.println("Mylock当前线程为"+thread.getName()+"进来了拿到锁了");
}
public void myUnlock(){
Thread thread = Thread.currentThread();
a.compareAndSet(thread,null);
System.out.println("MyUnlock当前线程为"+thread.getName()+"释放了锁");
}
public static void main(String[] args) {
SpinDemo spinDemo = new SpinDemo();
new Thread(()->{
spinDemo.myLock();
try {
TimeUnit.SECONDS.sleep(5);} catch (InterruptedException e) { e.printStackTrace();}
spinDemo.myUnlock();
},"A").start();
try {TimeUnit.SECONDS.sleep(1);} catch (InterruptedException e) { e.printStackTrace();}
new Thread(()->{
spinDemo.myLock();
spinDemo.myUnlock();
},"B").start();
}
}
读锁的共享锁可保证并发读是非常高效的,读写,写读,写写的过程是互斥的。
demo
/**
* 资源类
*/
class MyCaChe {
/**
* 保证可见性
*/
private volatile Map<String, Object> map = new HashMap<>();
private ReentrantReadWriteLock reentrantReadWriteLock = new ReentrantReadWriteLock();
/**
* 写
*
* @param key
* @param value
*/
public void put(String key, Object value) {
reentrantReadWriteLock.writeLock().lock();
try {
System.out.println(Thread.currentThread().getName() + "\t正在写入" + key);
//模拟网络延时
try {
TimeUnit.MICROSECONDS.sleep(300);
} catch (InterruptedException e) {
e.printStackTrace();
}
map.put(key, value);
System.out.println(Thread.currentThread().getName() + "\t正在完成");
} finally {
reentrantReadWriteLock.writeLock().unlock();
}
}
/**
* 读
*
* @param key
*/
public void get(String key) {
reentrantReadWriteLock.readLock().lock();
try {
System.out.println(Thread.currentThread().getName() + "\t正在读取");
//模拟网络延时
try {
TimeUnit.MICROSECONDS.sleep(300);
} catch (InterruptedException e) {
e.printStackTrace();
}
Object result = map.get(key);
System.out.println(Thread.currentThread().getName() + "\t正在完成" + result);
} finally {
reentrantReadWriteLock.readLock().unlock();
}
}
public void clearCaChe() {
map.clear();
}
}
/**
* Description:
* 多个线程同时操作 一个资源类没任何问题 所以为了满足并发量
* 读取共享资源应该可以同时进行
* 但是
* 如果一个线程想去写共享资源来 就不应该其他线程可以对资源进行读或写
*
* 小总结:
* 读 读能共存
* 读 写不能共存
* 写 写不能共存
* 写操作 原子+独占 整个过程必须是一个完成的统一整体 中间不允许被分割 被打断
*
* @author wql
* @date 2019-04-13 0:45
**/
public class ReadWriteLockDemo {
public static void main(String[] args) {
MyCaChe myCaChe = new MyCaChe();
for (int i = 1; i <= 5; i++) {
final int temp = i;
new Thread(() -> {
myCaChe.put(temp + "", temp);
}, String.valueOf(i)).start();
}
for (int i = 1; i <= 5; i++) {
int finalI = i;
new Thread(() -> {
myCaChe.get(finalI + "");
}, String.valueOf(i)).start();
}
}
}
死锁是指两个或两个以上的进程在执行过程中,因争夺资源而造成的一种互相等待的现场,若无外力干涉那它们都将无法继续推进下去,如果系统资源充足,进程的资源请求都能够得到满足,死锁出现的可能性就很低,否则会因为争夺有限的资源而陷入死锁;
class HoldThread implements Runnable {
private String lockA;
private String lockB;
public HoldThread(String lockA, String lockB) {
this.lockA = lockA;
this.lockB = lockB;
}
@Override
public void run() {
synchronized (lockA) {
System.out.println(Thread.currentThread().getName() + "\t 自己持锁" + lockA + "尝试获得" + lockB);
try {
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized (lockB) {
System.out.println(Thread.currentThread().getName() + "\t 自己持锁" + lockB + "尝试获得" + lockA);
}
}
}
}
/**
* Description:
* 死锁是指两个或者以上的进程在执行过程中,
* 因争夺资源而造成的一种相互等待的现象,
* 若无外力干涉那他们都将无法推进下去
*
* @author wql
* @date 2019-04-14 0:05
**/
public class DeadLockDemo {
public static void main(String[] args) {
String lockA = "lockA";
String lockB = "lockB";
new Thread(new HoldThread(lockA, lockB), "threadAAA").start();
new Thread(new HoldThread(lockB, lockA), "threadBBB").start();
}
}
死锁,简单来说就是两个或者两个以上的线程在执行的过程中,争夺同一个共享资源造成的相互等待的现象。
如果没有外部干预,线程会一直阻塞无法往下执行,这些一直处于相互等待资源的线程就称为死锁线程。
导致死锁的条件有四个,也就是这四个条件同时满足就会产生死锁。
按照死锁发生的四个条件,只需要破坏其中的任何一个,就可以解决,但是,互斥条件是没办法破坏的,因为这是互斥锁的基本约束,其他三方条件都有办法来破坏:
线程的优先级(Priority)决定了线程获得CPU运行的机会,优先级越髙获得的运行机 会越大,优先级越低获得的机会越小。
Java的线程有10个级别(准确地说是11个级别,级 别为〇的线程是JVM的,应用程序不能设置该级别)
事实上,不同的操作系统线程优先级是不相同的
,Windows有7个优先 级,Linux有140个优先级,Freebsd则有255个(此处指的是优先级总数,不同操作系统有 不同的分类,如中断级线程、操作系统级等,各个操作系统具体用户可用的线程数量也不相同)。
Java是跨平台的系统,需要把这个10个优先级映射成不同操作系统的优先级,于是界定了 Java的优先级只是代表抢占CPU的机会大小,优先级越髙,抢占CPU的机会越大,被优先执行的可能性越高,优先级相差不大,则抢占CPU的机会差别也不大,这就是导致了 优先级为9的线程可能比优先级为10的线程先运行。
Java的缔造者们也察觉到了线程优先问题,于是在Thread类中设置了三个优先级,此 意就是告诉开发者,建议使用优先级常量,而不是1到10随机的数字。常量代码如下:
public class Thread implementsRunnable {
//最低优先级
public final static int MIN一PRIORITY = 1;
//普通优先级,默认值
public final static int NORM一PRIORITY = 5;
//最高优先级
public final static int MAX一PRIORITY = 10;
}
在编码时直接使用这些优先级常量,可以说在大部分情况下max_priority的线程会 比NORM_PRIORITY的线程先运行,但是不能认为必然会先运行,不能把这个优先级做为核心业务的必然条件,Java无法保证优先级高肯定会先执行,只能保证髙优先级有更多的执 行机会。因此,建议在开发时只使用此三类优先级,没有必要使用其他7个数字,这样也可以保证在不同的操作系统上优先级的表现基本相同。
明白了这个问题,那可能有读者要问了:如果优先级相同呢?这很好办,也是由操作系统决定的,基本上是按照FIFO原则(先入先出,First Input First Output),但也是不能完全保证。 注意线程优先级推荐使用MIN_PRIORITY、NORM_PRIORITY、MAX_PRIORITY三个 级别,不建议使用其他7个数字。
java内的对象由三部分组成 对象头(内存布局)+实列数据+对其填充数据(可有可无,看前2个之和是否是8的倍数)
对象头由2部分组成 Mark Word+klass pointer
查看地址: https://openjdk.java.net/groups/hotspot/docs/HotSpotGlossary.html
mark word:
The first word of every object header. Usually a set of bitfields including synchronization state and identity hash code. May also be a pointer (with characteristic low bit encoding) to synchronization related information. During GC, may contain GC state bits.
每个gc管理的堆对象开头的公共结构(每个oop都指向一个对象头)包括堆对象的布局,类型,GC状态,同步状态和标识哈希码的基本信息,java对象和Jvm内部都一个通用的对象头格式
在使用Synchronized的时候,java对象几种状态?
5种: 无锁,偏向锁,轻量级锁,重量级锁,GC标记
对象头在64bit的jvm当中,占12的字节,共96bit, 其中 Mark word 占64it 存的东西不固定
Kclass pointer 占32bit 如果的地方说是64bit,也对,说明没开启指针压缩 在32bit的jvm当中,占8的字节,共64bit
其中 Mark word 占32it 存的东西不固定 Kclass pointer 占32bit
<dependencies>
<!-- https://mvnrepository.com/artifact/org.openjdk.jol/jol-core -->
<dependency>
<groupId>org.openjdk.jol</groupId>
<artifactId>jol-core</artifactId>
<version>0.9</version>
</dependency>
</dependencies>
public class abc {
public static void main(String[] args) {
System.out.println(ClassLayout.parseInstance("5").toPrintable());
}
}
无锁 - 偏向锁 - 轻量级锁 (自旋锁,自适应自旋)- 重量级锁
synchronized优化的过程和markword息息相关
用markword中最低的三位代表锁状态 其中1位是偏向锁位 两位是普通锁位
Object o = new Object()锁 = 0 01 无锁态
o.hashCode()001 + hashcode
Object o = new Object(); 001 无锁态
00000001 10101101 00110100 00110110
01011001 00000000 00000000 00000000
o.hashCode() 001+hashCode
little endian big endian
00000000 00000000 00000000 01011001 00110110 00110100 10101101 00000000
1)Little-endian:将低序字节存储在起始地址(低位编址)
2)Big-endian:将高序字节存储在起始地址(高位编址)
3.=========默认synchronized(o) 00 -> 轻量级锁默认情况 偏向锁个时延,默认是4秒 why? 因为JVM虚拟机自己一些默认启动的线程,里面有好多sync代码,这些sync代码启动时就知道肯定会竞争,如果使用偏向锁,就会造成偏向锁不断的进行锁撤销和锁升级的操作,效率较低。
-XX:BiasedLockingStartupDelay=0
4.==============如果设定上述参数
new Object () - > 101 偏向锁 ->线程ID为0 -> Anonymous BiasedLock
打开偏向锁,new出来的对象,默认就是一个可偏向匿名对象101
5.==============如果线程上锁
上偏向锁,指的就是,把markword的线程ID改为自己线程ID的过程
批量重偏向:当一个线程创建了大量对象并执行了初始的同步操作,后来另一个线程也来将这些对象作为锁对象进行操作,会导偏向锁重偏向的操作。
批量撤销:在多线程竞争剧烈的情况下,使用偏向锁将会降低效率,于是乎产生了批量撤销机制。
通过JVM的默认参数值,找一找批量重偏向和批量撤销的阈值。
设置JVM参数-XX:+PrintFlagsFinal,在项目启动时即可输出JVM的默认参数值
intx BiasedLockingBulkRebiasThreshold = 20 默认偏向锁批量重偏向阈值
intx BiasedLockingBulkRevokeThreshold = 40 默认偏向锁批量撤销阈值
当然我们可以通过
-XX:BiasedLockingBulkRebiasThreshold 和
-XX:BiasedLockingBulkRevokeThreshold 来手动设置阈值
1、批量重偏向和批量撤销是针对类的优化,和对象无关。
2、偏向锁重偏向一次之后不可再次重偏向。
3、当某个类已经触发批量撤销机制后,JVM会默认当前类产生了严重的问题,剥夺了该类的新实例对象使用偏向锁的权利
6. 如果线程竞争
撤销偏向锁,升级轻量级锁
线程在自己的线程栈生成LockRecord ,用CAS操作将markword设置为指向自己这个线程的LR的指针,设置成功者得到锁
7. 如果竞争加剧
竞争加剧:线程超过10次自旋, -XX:PreBlockSpin, 或者自旋线程数超过CPU核数的一半, 1.6之后,加入自适应自旋 Adapative Self Spinning , JVM自己控制
升级重量级锁:-> 向操作系统申请资源,linux mutex , CPU从3级-0级系统调用,线程挂起,进入等待队列,等待操作系统的调度,然后再映射回用户空间
偏向锁 - markword 上记录当前线程指针,下次同一个线程加锁的时候,不需要争用,只需要判断线程指针是否同一个,所以,偏向锁,偏向加锁的第一个线程 。hashCode备份在线程栈上 线程销毁,锁降级为无锁
争用 - 锁升级为轻量级锁 - 每个线程自己的LockRecord在自己的线程栈上,用CAS去争用markword的LR的指针,指针指向哪个线程的LR,哪个线程就拥锁
自旋超过10次,升级为重量级锁 - 如果太多线程自旋 CPU消耗过大,不如升级为重量级锁,进入等待队列(不消耗CPU-XX:PreBlockSpin
锁消除 lock eliminate
public void add(String str1,String str2){
StringBuffer sb = new StringBuffer();
sb.append(str1).append(str2);
}
我们都知道 StringBuffer 是线程安全的,因为它的关键方法都是被 synchronized 修饰过的,但我们看上面这段代码,我们会发现,sb 这个引用只会在 add 方法中使用,不可能被其它线程引用(因为是局部变量,栈私有),因此 sb 是不可能共享的资源,JVM 会自动消除 StringBuffer 对象内部的锁。
锁粗化 lock coarsening
public String test(String str){
int i = 0;
StringBuffer sb = new StringBuffer():
while(i < 100){
sb.append(str);
i++;
}
return sb.toString():
}
JVM 会检测到这样一连串的操作都对同一个对象加锁(while 循环内 100 次执行 append,没有锁粗化的就要进行 100 次加锁/解锁),此时 JVM 就会将加锁的范围粗化到这一连串的操作的外部(比如 while 虚幻体外),使得这一连串操作只需要加一次锁即可。
在高争用 高耗时的环境下synchronized效率更高
在低争用 低耗时的环境下CAS效率更高
synchronized到重量级之后是等待队列(不消耗CPU)
CAS(等待期间消耗CPU)
一切以实测为准
public static void main(String[] args) {
public static void main(String[] args) throws Exception {
//延时产生可偏向对象
Thread.sleep(5000);
//创造100个偏向线程t1的偏向锁
List<A> listA = new ArrayList<>();
Thread t1 = new Thread(() -> {
for (int i = 0; i <100 ; i++) {
A a = new A();
synchronized (a){
listA.add(a);
}
}
try {
//为了防止JVM线程复用,在创建完对象后,保持线程t1状态为存活
Thread.sleep(100000000);
} catch (InterruptedException e) {
e.printStackTrace();
}
});
t1.start();
//睡眠3s钟保证线程t1创建对象完成
Thread.sleep(3000);
out.println("打印t1线程,list中第20个对象的对象头:");
out.println((ClassLayout.parseInstance(listA.get(19)).toPrintable()));
//创建线程t2竞争线程t1中已经退出同步块的锁
Thread t2 = new Thread(() -> {
//这里面只循环了30次!!!
for (int i = 0; i < 30; i++) {
A a =listA.get(i);
synchronized (a){
//分别打印第19次和第20次偏向锁重偏向结果
if(i==18||i==19){
out.println("第"+ ( i + 1) + "次偏向结果");
out.println((ClassLayout.parseInstance(a).toPrintable()));
}
}
}
try {
Thread.sleep(10000000);
} catch (InterruptedException e) {
e.printStackTrace();
}
});
t2.start();
Thread.sleep(3000);
out.println("打印list中第11个对象的对象头:");
out.println((ClassLayout.parseInstance(listA.get(10)).toPrintable()));
out.println("打印list中第26个对象的对象头:");
out.println((ClassLayout.parseInstance(listA.get(25)).toPrintable()));
out.println("打印list中第41个对象的对象头:");
out.println((ClassLayout.parseInstance(listA.get(40)).toPrintable()));
}
}
public static void main(String[] args) throws Exception {
Thread.sleep(5000);
List<A> listA = new ArrayList<>();
Thread t1 = new Thread(() -> {
for (int i = 0; i <100 ; i++) {
A a = new A();
synchronized (a){
listA.add(a);
}
}
try {
Thread.sleep(100000000);
} catch (InterruptedException e) {
e.printStackTrace();
}
});
t1.start();
Thread.sleep(3000);
Thread t2 = new Thread(() -> {
//这里循环了40次。达到了批量撤销的阈值
for (int i = 0; i < 40; i++) {
A a =listA.get(i);
synchronized (a){
}
}
try {
Thread.sleep(10000000);
} catch (InterruptedException e) {
e.printStackTrace();
}
});
t2.start();
//———————————分割线,前面代码不再赘述——————————————————————————————————————————
Thread.sleep(3000);
out.println("打印list中第11个对象的对象头:");
out.println((ClassLayout.parseInstance(listA.get(10)).toPrintable()));
out.println("打印list中第26个对象的对象头:");
out.println((ClassLayout.parseInstance(listA.get(25)).toPrintable()));
out.println("打印list中第90个对象的对象头:");
out.println((ClassLayout.parseInstance(listA.get(89)).toPrintable()));
Thread t3 = new Thread(() -> {
for (int i = 20; i < 40; i++) {
A a =listA.get(i);
synchronized (a){
if(i==20||i==22){
out.println("thread3 第"+ i + "次");
out.println((ClassLayout.parseInstance(a).toPrintable()));
}
}
}
});
t3.start();
Thread.sleep(10000);
out.println("重新输出新实例A");
out.println((ClassLayout.parseInstance(new A()).toPrintable()));
}
AQS 全名 AbstractQueuedSynchronizer,意为抽象队列同步器,JUC(java.util.concurrent 包)下面的 Lock 和其他一些并发工具类都是基于它来实现的。AQS 维护了一个 volatile 的 state
和一个 CLH(FIFO)双向队列
AQS 使用一个 int 成员变量来表示同步状态,通过内置的 FIFO 队列来完成获取资源线程的排队工作。
AQS 使用 CAS 对该同步状态进行原子操作实现对其值的修改;
AQS 是多线程同步器,它是 J.U.C 包中多个组件的底层实现,如Lock、 CountDownLatch、Semaphore 等都用到了AQS.
从本质上来说,AQS 提供了两种锁机制,分别是排它锁,和 共享锁。
第一个方面,双向链表的优势:
第二个方面,说一下 AQS 采用双向链表的原因
使用CopyOnWriterSet即可
,HashSet 底层是HashMap ,只不过它的Value是写死的Present ,是一个Object对象
CopyOnWriterSet 底层也是CopyOnWriterArrayList
Synchronized 是Java 中的同步关键字
,Lock 是J.U.C 包中提供的接口,这个接口有很多实现类,其中就包括 ReentrantLock 重入锁;Lock 锁的粒度是通过它里面提供的 lock()和 unlock()方法决定的,包裹在这两个方法之间的代码能够保证线程安全性。而锁的作用域取决于 Lock 实例的生命周期
;从两个方面来回答:
run()
方法并且等待再去统计任务的完成数量。方法返回后再去统计任务的完成数量。future.get()
方法会一直阻塞,直到任务执行结束。因此,只要CountDownLatch
计数器,它可以通过初始化指定一个计数器进行倒计时,其中有两个方法分别是 await()阻塞线程,以及进行倒计时,一旦倒计时归零,所以被阻塞在 await()方法的线程都会被释放。基于这个问题,我简单总结一下,不管是线程池内部还是外部,要想知道线程是否执行结束,我们必须要获取线程执行结束后的状态,而线程本身没有返回值,所以只能通过阻塞-唤醒的方式来实现,future.get 和CountDownLatch 都是这样一个原理。
阻塞队列,是一种特殊的队列,它在普通队列的基础上提供了两个附加功能;
无界队列存在比较大的潜在风险,如果在并发量较大的情况下,线程池中可以几乎无限制的添加任务,容易导致内存溢出的问题!
CAS 是Java 中Unsafe 类里面的方法,它的全称是 CompareAndSwap,比较并交换的意思
。
它的主要功能是能够保证在多线程环境下,对于共享变量的修改的原子性。
举例:
比如说有这样一个场景(如图),有一个成员变量 state,默认值是 0,定义了一个方法 doSomething(),这个方法的逻辑是,判断 state 是否为 0 ,如果为 0,就修改成 1。
这个逻辑看起来没有任何问题,但是在多线程环境下,会存在原子性的问题,因为这里是一个典型的,Read - Write 的操作。一般情况下,我们会在 doSomething()这个方法上加同步锁来解决原子性问题。但是,加同步锁,会带来性能上的损耗,所以,对于这类场景,我们就可以使用CAS机制来进行优化
在doSomething()方法中,我们调用了 unsafe 类中的compareAndSwapInt()方法来达到同样的目的,这个方法有四个参数,分别是:当前对象实例、成员变量 state 在内存地址中的偏移量、预期值 0、期望更改之后的值 1。
CAS 机制会比较state 内存地址偏移量对应的值和传入的预期值0 是否相等,如果相等,就直接修改内存地址中 state 的值为 1.否则,返回false,表示修改失败,而这个过程是原子的,不会存在线程安全问题。
CompareAndSwap 是一个native 方法
,实际上它最终还是会面临同样的问题,就是先从内存地址中读取 state 的值,然后去比较,最后再修改。这个过程不管是在什么层面上实现,都会存在原子性问题。
所以呢,CompareAndSwap 的底层实现中,在多核 CPU 环境下,会增加一个Lock指令对缓存或者总线加锁
,从而保证比较并替换这两个指令的原子性。
CAS 主要用在并发场景中,比较典型的使用场景有两个。
AtomicInteger
,AtomicLong
。在多线程里面,要实现多个线程之间的通信,除了管道流以外,只能通过共享变量的方法来实现
,也就是线程 t1 修改共享变量s,线程t2 获取修改后的共享变量s,从而完成数据通信简单来说,守护线程就是一种后台服务线程,他和我们在 Java 里面创建的用户线程是一模一样的。
守护线程和用户线程的区别有几个点,这几个点也是守护线程本身的特性:
注意,Java 进程的终止与否,只和用户线程有关
。如果当前还有守护线程正在运行,也不会阻止Java 程序的终止。
因此,守护线程的生命周期依赖于用户线程
。
举个例子,JVM 垃圾回收线程就是一个典型的守护线程,它存在的意义是不断的处理用户线程运行过程中产生的内存垃圾。一旦用户线程全部结束了,那垃圾回收线程也就没有存在的意义了。由于守护线程的特性,所以它它适合用在一些后台的通用服务场景里面。
但是守护线程不能用在线程池或者一些IO 任务的场景里面
,因为一旦 JVM 退出之后,守护线程也会直接退出。
就会可能导致任务没有执行完或者资源没有正确释放
的问题。
Happens-Before 规
则里面的监视器锁规则,又保证了数据修改后对其他线程的可见性。空间换时间
的设计思想,也就是说在每个线程里面,都有一个容器来存储共享变量的副本,然后每个线程只对自己的变量副本来做更新操作,这样既解决了线程安全问题,又避免了多线程竞争加锁的开销。阻塞队列(BlockingQueue)是在队列的基础上增加了两个附加操作:
首先,ReentrantLock 是一种可重入的排它锁,主要用来解决多线程对共享资源竞争的问题。
它的核心特性有几个:
1.它支持可重入,也就是获得锁的线程在释放锁之前再次去竞争同一把锁的时候,不需要加锁就可以直接访问。
2.它支持公平和非公平特性
3.它提供了阻塞竞争锁和非阻塞竞争锁的两种方法,分别是 lock()和tryLock()。
然后,ReentrantLock 的底层实现有几个非常关键的技术
首先,线程池本质上是一种池化技术,而池化技术是一种资源复用的思想,比较常见的有连接池、内存池、对象池。
而线程池里面复用的是线程资源,它的核心设计目标,我认为有两个:
其次,我简单说一下线程池里面的线程复用技术。因为线程本身并不是一个受控的技术,也就是说线程的生命周期时由任务运行的状态决定的,无法人为控制。所以为了实现线程的复用,线程池里面用到了阻塞队列,简单来说就是线程池里面的工作线程处于一直运行状态,它会从阻塞队列中去获取待执行的任务,一旦队列空了,那这个工作线程就会被阻塞,直到下次有新的任务进来。
也就是说,工作线程是根据任务的情况实现阻塞和唤醒,从而达到线程复用的目的。最后,线程池里面的资源限制,是通过几个关键参数来控制的,分别是核心线程数、最大线程数。
核心线程数表示默认长期存在的工作线程,而最大线程数是根据任务的情况动态创建的线程,主要是提高阻塞队列中任务的处理效率。
首先,线程是系统级别的概念,在 Java 里面实现的线程,最终的执行和调度都是由操作系统来决定的,JVM 只是对操作系统层面的线程做了一层包装而已。
所以我们在Java 里面通过start 方法启动一个线程的时候,只是告诉操作系统这个线程可以被执行,但是最终交给 CPU 来执行是操作系统的调度算法来决定的。
因此,理论上来说,要在Java 层面去中断一个正在运行的线程,只能像类似于 Linux里面的kill 命令结束进程的方式一样,强制终止。
所以,Java Thread 里面提供了一个 stop 方法可以强行终止,但是这种方式是不安全的,因为有可能线程的任务还没有结束,导致出现运行结果不正确的问题。
要想安全的中断一个正在运行的线程,只能在线程内部埋下一个钩子,外部程序通过这个钩子来触发线程的中断命令。因此,在Java Thread 里面提供了一个 interrupt()方法
,这个方法配合 isInterrupted()方法使用,就可以实现安全的中断机制。
这种实现方法并不是强制中断,而是告诉正在运行的线程,你可以停止了,不过是否要中断,取决于正在运行的线程,所以它能够保证线程运行结果的安全性。
3.Synchronized 引入了锁升级的机制之后,如果有线程去竞争锁:
CompletableFuture 是JDK1.8 里面引入的一个基于事件驱动
的异步回调类
。
简单来说,就是当使用异步线程去执行一个任务的时候,我们希望在任务结束以后触发一个后续的动作。
而CompletableFuture 就可以实现这个功能。
举个简单的例子,比如在一个批量支付的业务逻辑里面,涉及到查询订单、支付、发送邮件通知这三个逻辑。
这三个逻辑是按照顺序同步去实现的,也就是先查询到订单以后,再针对这个订单发起支付,支付成功以后再发送邮件通知。而这种设计方式导致这个方法的执行性能比较慢。
所以,这里可以直接使用CompletableFuture,(如图),也就是说把查询订单的逻辑放在一个异步线程池里面去处理。然后基于CompletableFuture 的事件回调机制的特性,可以配置查询订单结束后自动触发支付,支付结束后自动触发邮件通知。从而极大的提升这个这个业务场景的处理性能!
CompletableFuture 提供了 5 种不同的方式,把多个异步任务组成一个具有先后关系的处理链,然后基于事件驱动任务链的执行。
thenCombine
(如图),把两个任务组合在一起,当两个任务都执行结束以后触发事件回调。thenCompose
(如图),把两个任务组合在一起,这两个任务串行执行,也就是第一个任务执行完以后自动触发执行第二个任务thenAccept
(如图),第一个任务执行结束后触发第二个任务,并且第一个任务的执行结果作为第二个任务的参数,这个方法是纯粹接受上一个任务的结果,不返回新的计算值。thenApply
(如图),和thenAccept 一样,但是它有返回值thenRun
(如图),就是第一个任务执行完成后触发执行一个实现了 Runnable 接口的任务。BLOCKED 和WAITING 都是属于线程的阻塞等待状态。
所以,在我看来,BLOCKED 和WAITING 两个状态最大的区别有两个:
Thread 和Runnable 接口的区别有 4 个。
首先,wait()方法是让一个线程进入到阻塞状态,而这个方法必须要写在一个 Synchronized 同步代码块里面。
因为wait/notify 是基于共享内存来实现线程通信的工具,这个通信涉及到条件的竞争,所以在调用这两个方法之前必须要竞争锁资源。当线程调用wait 方法的时候,表示当前线程的工作处理完了,意味着让其他竞争同一个共享资源的线程有机会去执行。但前提是其他线程需要竞争到锁资源,所以 wait 方法必须要释放锁,否则就会导致死锁的问题。
然后,Thread.sleep()方法,只是让一个线程单纯进入睡眠状态,这个方法并没有强制要求加synchronized 同步锁。
而且从它的功能和语义来说,也没有这个必要。当然,如果是在一个 Synchronized 同步代码块里面调用这个Thread.sleep,也并不会触发锁的释放。
最后,凡是让线程进入阻塞状态的方法,操作系统都会重新调度实现 CPU 时间片切换,这样设计的目的是提升 CPU 的利用率。
首先,线程池里面分为核心线程和非核心线程。核心线程是常驻在线程池里面的工作线程
它有两种方式初始化:
prestartAllCoreThreads
方法当线程池里面的队列满了的情况下,为了增加线程池的任务处理能力。线程池会增加非核心线程。
核心线程和非核心线程的数量,是在构造线程池的时候设置的,也可以动态进行更改。
由于非核心线程是为了解决任务过多的时候临时增加的,所以当任务处理完成后,工作线程处于空闲状态的时候,就需要回收。
因为所有工作线程都是从阻塞队列中去获取要执行的任务,所以只要在一定时间内,阻塞队列没有任何可以处理的任务,那这个线程就可以结束了。
这个功能是通过阻塞队列里面的poll
方法来完成的。这个方法提供了超时时间和超时时间单位这两个参数
当超过指定时间没有获取到任务的时候,poll 方法返回null,从而终止当前线程完成线程回收。
默认情况下,线程池只会回收非核心线程,如果希望核心线程也要回收,可以设置allowCoreThreadTimeOut
这个属性为true,一般情况下我们不会去回收核心线程。因为线程池本身就是实现线程的复用,而且这些核心线程在没有任务要处理的时候是处于阻塞状态并没有占用CPU 资源。
在Java 里面,一个线程只能调用一次 start()方法,第二次调用会抛出 IllegalThreadStateException
。
一个线程本身是具备一个生命周期的。
在Java 里面,线程的生命周期包括 6 种状态。
当我们第一次调用start()方法的时候,线程的状态可能处于终止或者非 NEW 状态下的其他状态。
再调用一次start(),相当于让这个正在运行的线程重新运行,不管从线程的安全性角度,还是从线程本身的执行逻辑,都是不合理的。因此为了避免这个问题,在线程运行的时候会先判断当前线程的运行状态。
JDK 中幕刃提供了 5 中不同线程池的创建方式:
newCachedThreadPool
是一种可以缓存的线程池,它可以用来处理大量短期的突发流量。newFixedThreadPool
是一种固定线程数量的线程池。newSingleThreadExecutor
只有一个工作线程的线程池。newScheduledThreadPool
,具有延迟执行功能的线程池可以用它来实现定时调度。newWorkStealingPool
,Java8 里面新加入的一个线程池,它内部会构建一个ForkJoinPool,利用工作窃取的算法并行处理请求。这些线程都是通过工具类Executors 来构建的,线程池的最终实现类是 ThreadPoolExecutor;
首先,Happens-Before
是一种可见性模型,也就是说,在多线程环境下。
原本因为指令重排序的存在会导致数据的可见性问题,也就是 A 线程修改某个共享变量对B 线程不可见。
因此,JMM 通过Happens-Before 关系向开发人员提供跨越线程的内存可见性保证。
如果一个操作的执行结果对另外一个操作可见,那么这两个操作之间必然存在 Happens-Before 管理。
其次,Happens-Before 关系只是描述结果的可见性,并不表示指令执行的先后顺序,也就是说只要不对结果产生影响,仍然允许指令的重排序。
最后,在JMM 中存在很多的Happens-Before 规则。
线程池里面采用了生产者消费者的模式,来实现线程复用
。
生产者消费者模型,其实就是通过一个中间容器来解耦生产者和消费者的任务处理过程。
提交任务到线程池里面的线程称为生产者线程
,它不断往线程池里面传递任务。这些任务会保存到线程池的阻塞队列里面。然后线程池里面的工作线程不断从阻塞队列获取任务去执行。当我们提交一个任务到线程池的时候,它的工作原理分为四步。
所以,如果希望这个任务不进入队列,那么只需要去影响第二步的执行逻辑就行了。 Java 中线程池提供的构造方法里面,有一个参数可以修改阻塞队列的类型。其中,就有一个阻塞队列叫 SynchronousQueue
(如图), 这个队列不能存储任何元素。它的特性是,每生产一个任务,就必须要指派一个消费者来处理,否则就会阻塞生产者。
基于这个特性,只要把线程池的阻塞队列替换成 SynchronousQueue。
就能够避免任务进入到阻塞队列,而是直接启动最大线程数去处理这个任务。
SimpleDateFormat 不是线程安全的
, SimpleDateFormat 类内部有一个Calendar 对象
引用, 它用来储存和这个SimpleDateFormat 相关的日期信息。
实际应用中,我认为有 4 种方法可以解决这个问题。
并行和并发是Java 并发编程里面的概念。
并行,是指在多核CPU 架构下,同一时刻同时可以执行多个线程的能力
。
并发,是指在同一时刻 CPU 能够处理的任务数量,也可以理解成CPU 的并发能力
。
所以,总的来说,并发是一个宏观概念,它指的是 CPU 能够承载的压力大小,并行是一个微观概念,它描述 CPU 同时执行多个任务的能力。
ThreadLocal 是一个用来解决线程安全性问题的工具。它相当于让每个线程都开辟一块内存空间,用来存储共享变量的副本。然后每个线程只需要访问和操作自己的共享变量副本即可,从而避免多线程竞争同一个共享资源。
它的工作原理很简单(如图)
每个线程里面有一个成员变量 ThreadLocalMap。当线程访问用ThreadLocal 修饰的共享数据的时候
这个线程就会在自己成员变量 ThreadLocalMap 里面保存一份数据副本。key 指向ThreadLocal 这个引用,并且是弱引用关系,而 value 保存的是共享数据的副本。因为每个线程都持有一个副本,所以就解决了线程安全性问题。
Thread 中的成员变量ThreadLocalMap,它里面的可以 key 指向ThreadLocal 这个成员变量,并且它是一个弱引用
所谓弱引用,就是说成员变量 ThreadLocal 允许在这种引用关系存在的情况下,被 GC回收。
一旦被回收,key 的引用就变成了null,就会导致这个内存永远无法被访问,造成内存泄漏。
规避内存泄漏的方法有两个:
所以我认为最好是在使用完以后调用 remove 方法移除