进程和线程的联系和区别

进程:是程序在执行中进行资源分配和调度的基本单位

线程: 是进程的进一步划分,是进程的一个执行体,更小的独立运行的基本单位,亦称轻量级线程

举个栗子:

我们在使用QQ的时候假设是一个进程,那我们使用QQ的发短信,发文件,发说说等功能就可以说是线程。

进程和线程的区别:

  • 进程是独立的地址空间,但同一个进程内的线程共享本进程的地址空间

  • 同一进程的线程共享资源比如CPU,内存等等,但进程之间是相互独立的

  • 一个进程崩溃后,在保护模式下不会对其他进程产生影响

  • 一个线程崩溃整个进程都死掉,所以多进程要比多线程要健壮

  • 进程可以独立运行,且每个独立的进程有一个程序运行的入口、顺序执行序列和程序入口

  • 线程不能独立执行,必须依存在应用程序中,由应用程序提供多个线程的执行控制,线程是处理器调度的基本单位,但进程不是

  • 两者都支持并发执行。进程切换时,消耗的资源大,效率高。所以涉及到频繁的切换时,使用线程要好于进程。如果同时要求进行并且又要共享某些变量的并发操作,就只能用线程(进程之间是想独立的)

线程的生命周期

内容摘录自:https://zhuanlan.zhihu.com/p/58966050

v2-3640b7f86a072bc188199aa8bb76c271_720w.jpg

下面详细说明下,线程共有6种状态:

new,runnable,blocked,waiting,timed waiting,terminated

1,当进入synchronized同步代码块或同步方法时,且没有获取到锁,线程就进入了blocked状态,直到锁被释放,重新进入runnable状态

2,当线程调用wait()或者join时,线程都会进入到waiting状态,当调用notify或notifyAll时,或者join的线程执行结束后,会进入runnable状态

3,当线程调用sleep(time),或者wait(time)时,进入timed waiting状态,

当休眠时间结束后,或者调用notify或notifyAll时会重新runnable状态。

4,程序执行结束,线程进入terminated状态

案例篇

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
/**
* @author huangguizhao
* 测试线程的状态
*/
public class ThreadStateTest {
public static void main(String[] args) throws InterruptedException {
Thread thread = new Thread(new Task());
System.out.println(thread.getState());//NEW
thread.start();
System.out.println(thread.getState());//RUNNABLE
//保险起见,让当前主线程休眠下
Thread.sleep(10);
System.out.println(thread.getState());//terminated
}
}
class Task implements Runnable{
@Override
public void run() {
for (int i = 0; i < 100; i++) {
System.out.println(i);
}
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
public class ThreadStateTest {
public static void main(String[] args) throws InterruptedException {
BlockTask task = new BlockTask();
Thread t1 = new Thread(task);
Thread t2 = new Thread(task);
t1.start();
t2.start();
//从严谨的角度来说,t1线程不一定会先执行,此处是假设t1先执行
System.out.println(t1.getState());//RUNNABLE
System.out.println(t2.getState());//BLOCKED
Thread.sleep(10);
System.out.println(t1.getState());//TIMED_WAITING
Thread.sleep(1000);
System.out.println(t1.getState());//WAITING
}
}

class BlockTask implements Runnable{

@Override
public void run() {
synchronized (this){
//另一个线程会进入block状态
try {
//目的是让线程进入waiting time状态
Thread.sleep(1000);
//进入waiting状态
wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}

注意:

blocked,waiting,timed waiting 我们都称为阻塞状态

上述的就绪状态和运行状态,都表现为runnable状态

synchornized原理

相信只要对Java的多线程有一定了解的同学就一定知道这个关键字,我这里举一个大一学习数据库时,老师举的一个例子:

一个人和他的夫人有两张一模一样的银行卡(现实中是不存在的),卡里有1000块,在当地的两家银行同时并发的取1000块钱,是不是两个人都成功取出钱呢?

答案毫无疑问是否定的,要是这样还能成功,那银行早就关门倒闭了。

老师给我们讲了锁的机制,就是当取钱的请求发出后,就会有一个锁的机制,只有等上一个人的操作执行完后,下一个人才能进行取钱,这就不会出现取出两倍钱的现象。

当然,可能老师说的是数据库的行级锁或者表级锁,但我们这里也可以引申到Java中,当两个线程都去操作一个账户,那就必然需要锁的机制来控制线程的执行,synchorinized就是一把这样的锁!

关于synchornized的使用,我在JUC中已经讲到,这里就再讲,这里的核心是讲解synchornized的原理。

废话不多说,直接上代码测试:

1
2
3
4
5
6
7
8
9
10
11
12
13
/**
* syschronized原理分析
* @author 路飞
* @create 2021/3/22 10:57
*/
public class SynchorizedDemo {
public static void main(String[] args) {
Object o = new Object();
synchronized (o){
System.out.println("ok");
}
}
}

用例也很简单,创建了一个Object对象,通过synchorized对它进行加锁,拿到锁打印OK

通过JDK自带的反编译工具javap,我们反编译它的字节码文件,看它在JVM运行时真正的面貌

public static void main(java.lang.String[]);

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
descriptor: ([Ljava/lang/String;)V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=4, args_size=1
0: new #2 // class java/lang/Object
3: dup
4: invokespecial #1 // Method java/lang/Object."<init>":()V 无参构造
7: astore_1
8: aload_1
9: dup
10: astore_2
11: monitorenter // 加锁
12: getstatic #3 // Field java/lang/System.out:Ljava/io/PrintStream;
15: ldc #4 // String ok
17: invokevirtual #5 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
20: aload_2
21: monitorexit //释放锁
22: goto 30
25: astore_3
26: aload_2
27: monitorexit
28: aload_3
29: athrow
30: return

可以很清楚的看到,出现monitorenter和monitorexit,对于其他的JVM指令就是一些拷贝,赋值等操作,这里我们只需关注monitorenter和monitorexit两条JVM指令即可;

synchronized是java提供的原子性内置锁,这种内置的并且使用者看不到的锁也被称为监视器锁,使⽤

synchronized之后,会在编译之后在同步的代码块前后加上monitorenter和monitorexit字节码指令,

他依赖操作系统底层互斥锁实现。他的作用主要就是实现原子性操作和解决共享变量的内存可见性问

题。

执行monitorenter指令时会尝试获取对象锁,如果对象没有被锁定或者已经获得了锁,锁的计数器+1。

此时其他竞争锁的线程则会进⼊等待队列中。

执行monitorexit指令时则会把计数器-1,当计数器值为0时,则锁释放,处于等待队列中的线程再继续

竞争锁。

synchronized是排它锁,当⼀个线程获得锁之后,其他线程必须等待该线程释放锁后才能获得锁,而且

由于Java中的线程和操作系统原生线程是⼀⼀对应的,线程被阻塞或者唤醒时时会从用户态切换到内核

态,这种转换非常消耗性能。

从内存语义来说,加锁的过程会清除⼯作内存中的共享变量,再从主内存读取,而释放锁的过程则是将

工作内存中的共享变量写回主内存。

若再深入了解,synchronized实际上有两个队列waitSetentryList

  1. 当多个线程进⼊同步代码块时,首先进入entryList
  2. 有⼀个线程获取到monitor锁后,就赋值给当前线程,并且计数器+1
  3. 如果线程调用wait方法,将释放锁,当前线程置为null,计数器-1,同时进⼊waitSet等待被唤醒,

调用notify或者notifyAll之后又会进⼊entryList竞争锁

  1. 如果线程执行完毕,同样释放锁,计数器-1,当前线程置为null

需要注意的是:

方法级别的 synchronized 不会在字节码指令中有所体现,使用ACC_SYNCHRONIZED标记符隐式的实现。

锁的优化

这一块很难,我找了一篇很不错的博文,有时间一定要多读读!

Java中的锁[原理、锁优化、CAS、AQS]

CAS的原理

CAS叫做CompareAndSwap,比较并交换,主要通过处理器的指令来保证操作的原子性,它包含三个操作数:

  1. 变量内存地址,V表示
  2. 旧的预期值,A表示
  3. 准备设置的新值,B表示

当指向CAS指令时,只有当V等于A时,才会用B去更新V的值,否则不会执行更新操作。

CAS的缺点

主要有3点:

ABA问题:ABA的问题是指在CAS更新的过程中,当读取到的值是A,然后准备赋值的时候仍然是A,但实际上有可能A的值被改成了B,然后又改回了A,这个CAS更新的漏洞就叫做ABA。只是ABA的问题大部分场景下都不影响并发的最终效果。

Java中有AtomicStampedReference来解决这个问题,他加入了预期标准和更新后标准两个字段,更新时不光检查值,还要检查当前的标志是否等于预期标志,全部相等的话才会更新。

测试代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
/**
* CAS 解决ABA问题
* 类似乐观锁!!
* @author 路飞
* @create 2021/1/22
*/
public class CASDemo2 {


//Integer使用了对象缓存机制,默认范围是-128 ~ 127推荐使用静态工厂方法valueOf获取对象实例,
// 而不是new ,因为valueOf使用缓存,而new -定会创建新的对象分配新的内存空间;
//CAS compareAndSet:比较并交换
public static void main(String[] args) {

AtomicStampedReference<Integer> atomicStampedReference = new AtomicStampedReference<Integer>(1,1);

new Thread(()->{
int stamp = atomicStampedReference.getStamp();
System.out.println("a1=>"+atomicStampedReference.getStamp());

try {
TimeUnit.SECONDS.sleep(2);
} catch (InterruptedException e) {
e.printStackTrace();
}
atomicStampedReference.compareAndSet(1,2,atomicStampedReference.getStamp(),atomicStampedReference.getStamp()+1);
System.out.println("a2=>"+atomicStampedReference.getStamp());

atomicStampedReference.compareAndSet(2,1,atomicStampedReference.getStamp(),atomicStampedReference.getStamp()+1);
System.out.println("a3=>"+atomicStampedReference.getStamp());
},"a").start();


//另外一个线程
new Thread(()->{
int stamp = atomicStampedReference.getStamp();
System.out.println("b1=>"+stamp);

try {
TimeUnit.SECONDS.sleep(2);
} catch (InterruptedException e) {
e.printStackTrace();
}

atomicStampedReference.compareAndSet(1,6,atomicStampedReference.getStamp(),atomicStampedReference.getStamp()+1);
System.out.println("b2=>"+atomicStampedReference.getStamp());


},"b").start();
}

}

循环时间长开销大:自旋锁CAS的方式如果长时间不成功,会给CPU带来很大的开销。

只能保证一个共享变量的原子操作:只对一个共享变量操作可以保证原子性,但是多个则不行,多个可以通过AtomicReference来处理或者使用锁synchronized来实现

ReentrantLock原理?它和synchronized的区别

相比于synchronized,ReentrantLock需要显式的获取锁和释放锁,相对现在基本上都是用JDK7和JDK8的版本,ReentrantLock的效率和synchronized区别基本可以持平。他们的主要区别有以下几点:

  1. synchronized是Java语言关键字,是原生语法层面的互斥,需要JVM实现。而ReentrantLock它是JDK1.5之后提供的AIP层面的互斥锁,需要lock()和unlock()方法配合try/finaly语句块来完成

  2. 等待可中断,等待可中断是指当前持有锁的线程长期不释放锁的时候,正在等待的线程可以选择放弃等待改为处理其他事情。可等待特性对处理执行时间非常长的同步快很有帮助。synchronized就不支持等待可中断,一个线程持有锁不释放,另一个线程就会一直等待。ReentrantLock则可以中断等待,去做别的事情

  3. 公平锁,公平锁就是线程不能插队,严格的先来后到。非公平锁就是线程可以插队。是指synchronized和ReentrantLock默认都是非公平锁,但ReentrantLock可以通过构造函数传参变成公平锁,但这样性能也会急剧下降

  4. 锁绑定多个条件。synchronized中,锁对象的wait()和notify()或notifyAll()方法可以实现一个隐含的条件。但如果要多于一个的条件关联,就不得不额外添加一把锁。ReentrantLock可以同时绑定多个Condition对象,只需多次调用new Condition()即可

ReentrantLock基于AQS(AbstractQueueSynchronizer 抽象队列同步器)实现,AQS也是用来构建锁或者其他同步组件的基础框架。大名鼎鼎的AQS真的来了!!

  1. 使用一个int类型的成员变量表示同步状态

    • getState():获取当前同步状态
    • setState(int newState):设置当前同步状态
    • compareAndSetState(int expect,int update):使用CAS设置当前状态,该方法能够保证状态设置的原子性。
  2. 通过内置的FIFO双向队列来完成获取锁线程的排队工作

    • 同步器包含两个节点类型的应用,一个指向节点,一个指向尾节点,未获取的线程会创建节点线程安全(compareAndSetTail)的加入队列尾部。同步队列遵循FIFO,首节点是获取同步状态成功的节点。

    • 未获取到锁的线程将创建一个节点,设置到尾节点

    • 首节点的线程在释放锁时,将会唤醒后继节点。而后继节点将会在获取锁成功时将自己设置成首节点。。

      总的来说就是:

      AQS内部维护⼀个state状态位,尝试加锁的时候通过CAS(CompareAndSwap)修改值,如果成功设置为

      1,并且把当前线程ID赋值,则代表加锁成功,⼀旦获取到锁,其他的线程将会被阻塞进⼊阻塞队列⾃

      旋,获得锁的线程释放锁的时候将会唤醒阻塞队列中的线程,释放锁的时候则会把state重新置为0,同

      时当前线程ID置为空。

HashMap原理

这个我上一篇博文已经彻底分析过,这里就不再说了,贴个链接:

HashMap源码分析

多线程环境下怎么使用Map?ConcurrentHashMap有了解过吗?

HashMap由于底层并没有加入同步锁的机制,所以会出现线程安全问题,所以解决方法有:

  1. 使用Collections.synchronizedMap()同步加锁的方式
  2. 还可以使用HashTable,虽然是线程安全,但性能很差
  3. 在多线程环境下,可以使用JUC下的ConcurrentHashMap

ConcurrentHashmap在JDK1.7和1.8的版本改动比较大,1.7使⽤Segment+HashEntry分段锁的方式实

现,1.8则抛弃了Segment,改为使⽤CAS+synchronized+Node实现,同样也加入了红⿊树,避免链表

过长导致性能的问题。

关于ConcurrentHashMap的源码分析,后面单独写一篇。

volatile原理

相比synchronized的加锁方式来解决共享变量的内存可见性问题,volatile就是更轻量的选择,他没有上下文切换的额外开销成本。使用volatile声明的变量,可以确保值被更新的时候其他线程立刻可见。

volatile使用内存屏障来保证不会发生指令重排,解决内存可见性的问题,但不保证原子性。

线程并不会时时和主内存进行直接交互,线程都是从主内存中读取共享变量到工作内存来操作,完成之后再把结果写回主内存,但是这样就会带来可见性问题。举个例子,假设现在我们是两级缓存的双核CPU架构,包含L1、L2两级缓存。对于什么是工作内存和主存我在JUC系列已经讲过,这里就不再继续讲。

  1. 线程A首先获取变量X的值,由于最初两级缓存都是空,所以直接从主内存中读取X,假设X初始值为0,线程A读取之后把X值都修改为1,同时写回主内存。这时候缓存和主内存的情况如下:

  2. 线程B也这样同时读取x的值,由于L2缓存已经有缓存x=1,所以直接从L2缓存读取,之后线程B把x

    修改为2,同时写回L2和主内存。这是的x值如下:

    那么线程A如果再想获取变量x的值,因为L1缓存已经有了x=1,所以这时候变量内存不可见问题就产生了,B修改为2的值对A来说没有任何感知

    这时,可以通过volatile修设变量,当线程A再次读取变量x的话,CPU就会根据缓存一致性协议强制线程A重新从主内存加载最新的值到自己的工作内存,而不是直接用缓存中的值。

    再来说内存屏障的问题,volatile修设之后会加入不同的内存屏障来保证可见性的问题能正确执行。这里写的屏障基于书中提供的内容,但实际上由于CPU架构不同,重排序的策略也不同,提供的内存屏障也一样,比如在x86平台上,只有StoreLoad一致内存屏障。

    1. StoreStore屏障,保证上面的普通写不和volatile写发⽣重排序
    2. StoreLoad屏障,保证volatile写与后⾯可能的volatile读写不发⽣重排序
    3. LoadLoad屏障,禁止volatile读与后⾯的普通读重排序
    4. LoadStore屏障,禁⽌volatile读和后⾯的普通写重排序

JMM内存模型的理解?为什么需要JMM

本身随着CPU和内存的发展速度差异的问题,导致CPU的速度远快于内存,所以现在的CPU加⼊了高速
缓存,高速缓存⼀般可以分为L1、L2、L3三级缓存。基于上面的例子我们知道了这导致了缓存⼀致性的
问题,所以加⼊了缓存⼀致性协议,同时导致了内存可见性的问题,而编译器和CPU的重排序导致了原
子性和有序性的问题,JMM内存模型正是对多线程操作下的⼀系列规范约束,因为不可能让程序员的代
码去兼容所有的CPU,通过JMM我们才屏蔽了不同硬件和操作系统内存的访问差异,这样保证了Java程序在不同的平台下达到一致的内存访问效果,同时也是保证在高并发的时候能够正确执行。

原子性:Java内存内存模型通过read、load、assign、use、store、write来保证原子性操作,另外还有lock和unlock,直接对应着synchronized关键字的monitorenter和monitorexit字节码指令。

可见性:可见性的问题在上面已经回到了,Java保证可见性可以通过volatile、synchornized、final来实现。

有序性:由于处理器和编译器的重排序导致的有序性问题,Java通过volatile、synchornized来保证。

happen-before规则

虽然指令重排提高了并发的性能,但是Java虚拟机会对指令重排做出⼀些规则限制,并不能让所有的指

令都随意的改变执行位置,主要有以下几点:

  1. 单线程每个操作,happen-before于该线程中任意后续操作

  2. volatile写happen-before与后续对这个变量的读

  3. synchronized解锁happen-before后续对这个锁的加锁

  4. final变量的写happen-before于final域对象的读,happen-before后续对final变量的读

  5. 传递性规则,A先于B,B先于C,那么A⼀定先于C发生

工作内存和主存到底是什么?

主存就可以认为就是物理内存,Java内存模型中实际就是虚拟机内存的一部分。而工作内存就是CPU缓存,他可能是寄存器也可能是L1\L2\L3缓存,都是有可能的。

ThreadLocal的理解和原理

ThreadLocal的作用:

为每个线程创建一个副本,实现在线程的上下文传递同一个对象。

这里我们写一个测试代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
/**
* ThreadLocal 测试类
* @author 路飞
* @create 2021/3/24 8:06
*/
public class WithThreadLocal {
private static ThreadLocal<Integer> num = new ThreadLocal<Integer>() {
// 重写这个方法,可以修改“线程变量”的初始值,默认是null
@Override
protected Integer initialValue() {
return 0;
}
};

public static void main(String[] args) {

new Thread(()->{
if (num.get().equals(0)){
num.set(1);
}
System.out.println(Thread.currentThread().getName()+"--->"+num.get());
}).start();

new Thread(()->{
if (num.get().equals(0)){
num.set(2);
}
System.out.println(Thread.currentThread().getName()+"--->"+num.get());
}).start();

new Thread(()->{
if (num.get().equals(0)){
num.set(3);
}
System.out.println(Thread.currentThread().getName()+"--->"+num.get());
}).start();

System.out.println("最终的num--->"+num.get());
}
}

若没有ThreadLocal相关的知识,这段代码其实最终的也不能确定(线程的执行顺序是凭CPU调度的,这个和代码顺序关系不大),因为我们只能把num当作一个静态的全局变量,没有对它进行任何的加锁操作,在多线程环境下会有线程安全问题,起码打印的不会是num的初始值0,但我们运行发现最终的num就是0,在每条线程中打印的数据也都是注入的数据,是不是很奇怪?

1
2
3
4
Thread-0--->1
Thread-1--->2
最终的num--->0
Thread-2--->3

查阅资料,发现TheadLoca相关资料发现,它为每一个线程都提供了变量的副本,使得每个线程在某一时间访问到的并不是同一个对象,这样就隔离了多个线程对数据的数据共享。这就解释了为什么明明线程对它进行了修改,但为什么还是初始值的原因!

接下来我们就翻读源码,看看ThreadLocal的内部实现原理和常用方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
public class Thread implements Runnable {

static class ThreadLocalMap {

static class Entry extends WeakReference<ThreadLocal<?>> {
// 与当前ThreadLocal相关的对象
Object value;
Entry(ThreadLocal<?> k, Object v) {
super(k);
value = v;
}
}

// 初始容量
private static final int INITIAL_CAPACITY = 16;

// 存放信息的数组
private Entry[] table;

// 当前容器大小
private int size = 0;

// 当容量到达阈值就会进行扩容
private int threshold; // Default to 0

// 设置阈值threshold为数组长度的2/3
private void setThreshold(int len) {
threshold = len * 2 / 3;
}

// ThreadLocalMap的构造器,可以看出key是经过ThreadLocal内部一个变量threadLocalHashCode
// 计算而来的一个索引位置,稍后详解
ThreadLocalMap(ThreadLocal<?> firstKey, Object firstValue) {
table = new Entry[INITIAL_CAPACITY];
int i = firstKey.threadLocalHashCode & (INITIAL_CAPACITY - 1);
table[i] = new Entry(firstKey, firstValue);
size = 1;
setThreshold(INITIAL_CAPACITY);
}
}

创建一个ThreadLocalMap时,实际上内部是构建了一个Entry类型的数组,Entry是类似Map的Key-Value结构的,Key是根据当前ThreadLocal计算来了一个hashCode,Value就是要保存的线程变量的副本(如上文中的SimpleDateFormat)。key初始化大小为16,阈值threshold为数组长度的2/3,Entry类型为,有一个弱引用指向ThreadLocal对象。

所以每个Thread内部都维护这一个类似Map(虽然不是,但是可以简单的认为是HashMap),当我们创建一个ThreadLocal后,实际上是把当前的ThreadLocal信息存放到Thread内部所维护的ThreadLocalMap中。ThreadLocalMap是对当前线程中所有的方法都开放的,所以当就做到了每个线程共享,接下来进行详细分析。

继续看ThreadLocal的几个常用方法,其实真的就几个,这里把get(),set(),setInitialValue()拿出来讲讲

1
2
3
4
5
6
7
8
public void set(T value) {
Thread t = Thread.currentThread();
ThreadLocalMap map = getMap(t);
if (map != null)
map.set(this, value);
else
createMap(t, value);
}

代码非常简单,拿到当前线程对象,从上面的map获取map对象,若为空就以向map中添以t为key,value就是当前对象,若不为空,就刷新value。到这是不是很多同学会疑惑,那这样的操作map的key不就不一样了吗,一个是当前线程对象,一个是this,也就是ThreadLocal对象,我们点开createMap()源码发现:

1
2
3
void createMap(Thread t, T firstValue) {
t.threadLocals = new ThreadLocalMap(this, firstValue);
}

没错,这里直接修改的是引用,t的类型最后就是ThreadLocal了,所以还是以ThreadLocal为key,传入的参数作为value。

1
2
3
4
5
6
7
8
9
10
11
12
public T get() {
Thread t = Thread.currentThread();
ThreadLocalMap map = getMap(t);
if (map != null) {
ThreadLocalMap.Entry e = map.getEntry(this);
if (e != null) {
T result = (T)e.value;
return result;
}
}
return setInitialValue();
}

get()方法没什么好讲的,就是从map中的Entry取值,假如有阅读过HashMap源码的经验,这里其实就是先从map中拿到key(ThreadLoacl),再判断map是不是空,不为空就是直接获取值,然后返回,为空就调用setInitialValue()进行初始化操作。

1
2
3
4
5
6
7
8
9
10
private T setInitialValue() {
T value = initialValue();
Thread t = Thread.currentThread();
ThreadLocalMap map = getMap(t);
if (map != null)
map.set(this, value);
else
createMap(t, value);
return value;
}

可以看到setInitialValue()还是和createMap相关,把数据存入ThreadLocalMap的操作。

上文中我们清楚了ThreadLocalMap其实就是一个Entry类型的数组,类似map,任何Map都要解决的的就是哈希冲突。其中int i = firstKey.threadLocalHashCode & (INITIAL_CAPACITY - 1);的i是ThreadLocal存放在ThreadLocalMap中的索引位置,然后threadLocalHashCode的具体细节:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
private final int threadLocalHashCode = nextHashCode();

private static int nextHashCode() {
return nextHashCode.getAndAdd(HASH_INCREMENT);
}

private static int nextHashCode() {
return nextHashCode.getAndAdd(HASH_INCREMENT);
}

private static AtomicInteger nextHashCode =
new AtomicInteger();

ThreadLocalMap(ThreadLocal<?> firstKey, Object firstValue) {
table = new Entry[INITIAL_CAPACITY];
int i = firstKey.threadLocalHashCode & (INITIAL_CAPACITY - 1);
table[i] = new Entry(firstKey, firstValue);
size = 1;
setThreshold(INITIAL_CAPACITY);
}

每一个ThreadLocal都会根据nextHashCode生成一个int值,作为哈希值。然后根据这个哈希值和数组的长度len-1(因为len的长度总是2的倍数,减一的话就可以保证低N位都是1)进行求和,从而获取哈希值的低N位,从而获取再数组中的索引位置。而且nextHashCode的类型是AtomicInteger,这个就是为了在多线程环境下能保证数值操作原子性的类。

如何解决哈希冲突呢?

熟悉的HashMap发生哈希冲突我们都很熟悉了,通过链表或者红黑树进行解决,而ThreadLocalMap它本身就是一个很简单Entry数组,并不像HashMap具有那么复杂的数据结构,那么ThreadLocalMap是如何解决的呢?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
private void set(ThreadLocal<?> key, Object value) {
ThreadLocal.ThreadLocalMap.Entry[] tab = table;
int len = tab.length;
// 求索引位置
int i = key.threadLocalHashCode & (len-1);
// 如果要存放的i位置有数据,就说明发生了哈希冲突
for (ThreadLocal.ThreadLocalMap.Entry e = tab[i];
e != null;
e = tab[i = nextIndex(i, len)]) {
ThreadLocal<?> k = e.get();

// 如果是同一个ThreadLocal对象,就直接覆盖
if (k == key) {
e.value = value;
return;
}

// 如果key为null,则替换它的位置
if (k == null) {
replaceStaleEntry(key, value, i);
return;
}

// 否则就nextIndex(i, len),去找下一个位置
}
tab[i] = new ThreadLocal.ThreadLocalMap.Entry(key, value);
int sz = ++size;
if (!cleanSomeSlots(i, sz) && sz >= threshold)
rehash();
}

如果发生哈希冲突采用线性探测的方式,主要就是判断当前位置是否可以替换,如果发现这个位置上已经有其他key值的元素被占用,则利用固定的算法寻找一定步长的下个位置,依次判断,直至找到能够存放的位置。

1
2
3
private static int nextIndex(int i, int len) {
return ((i + 1 < len) ? i + 1 : 0);
}

ThreadLocal内存泄漏

但是有些时候使用ThreadLocal是会发生内存泄漏的,而为什么会发生内存泄漏呢?下面是我理解的一些答案:
如果ThreadLocal没有外部强引用,那么在发生垃圾回收的时候,ThreadLocal就必定会被回收,而ThreadLocal又作为Map中的key,ThreadLocal被回收就会导致一个key为null的entry,外部就无法通过key来访问这个entry,垃圾回收也无法回收,这就造成了内存泄漏

解决方案
解决办法是每次使用完ThreadLocal都调用它的remove()方法清除数据,或者按照JDK建议将ThreadLocal变量定义成private static,这样就一直存在ThreadLocal的强引用,也就能保证任何时候都能通过ThreadLocal的弱引用访问到Entry的value值,进而清除掉。

ThreadLocal在开发中的应用场景

比如:hibernate管理session,mybatis管理sqlsession,其内部都是采用ThreadLocal来实现的。

前提知识:不管是什么框架,最本质的操作都是基于JDBC,当我们需要跟数据库打交道的时候,都需要有一个connection。

那么,当我们需要在业务层实现事务控制时,该如何达到这个效果?

我们构建下代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
public class UserService {

//省略接口的声明
private UserDao userDao = new UserDao();
private LogDao logDao = new LogDao();

//事务的边界放在业务层
//JDBC的封装,connection
public void add(){
userDao.add();
logDao.add();
}
}

public class UserDao {

public void add(){
System.out.println("UserDao add。。。");
//创建connection对象
//connection.commit();
//connection.rollback();
}
}

public class LogDao {

public void add(){
System.out.println("LogDao add。。。");
//创建connection对象
//connection.commit();
//connection.rollback();
}
}

如果代码按上面的方式来管理connection,我们还可以保证service的事务控制吗?

这是不行的,假设第一个dao操作成功了,那么它就提交事务了,而第二个dao操作失败了,它回滚了事务,但不会影响到第一个dao的事务,因为上面这么写是两个独立的事务

那么怎么解决。

上面的根源就是两个dao操作的是不同的connection

所以,我们保证是同个connection即可

1
2
3
4
5
6
7
//事务的边界放在业务层
//JDBC的封装,connection
public void add(){
Connection connection = new Connection();
userDao.add(connection);
logDao.add(connection);
}

上面的方式代码不够优雅

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
public class ConnectionUtils {

private static ThreadLocal<Connection> threadLocal = new ThreadLocal<>();

public static Connection getConnection(){
Connection connection = threadLocal.get();
if(connection == null){
connection = new Connection();
threadLocal.set(connection);
}
return connection;
}
}

public class UserDao {

public void add(){
System.out.println("UserDao add。。。");
//创建connection对象
//connection.commit();
//connection.rollback();
Connection connection = ConnectionUtils.getConnection();
System.out.println("UserDao->"+connection);
}
}

到此,我们可以保证两个dao操作的是同一个connection

引用类型有哪些

引用类型主要分为强软弱虚四种:

  • 强引用指的就是代码中普遍存在的赋值⽅式,比如A a = new A()这种。强引⽤关联的对象,永远不

会被GC回收。

  • 软引用可以用SoftReference来描述,指的是那些有⽤但是不是必须要的对象。系统在发⽣内存溢

出前会对这类引用的对象进行回收。

  • 弱引用可以用WeakReference来描述,他的强度比软引⽤更低⼀点,弱引用的对象下⼀次GC的时

候⼀定会被回收,而不管内存是否足够。

  • 虚引用也被称作幻影引用,它是最弱的一种引用关系。一个对象是否有虚引用的 存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。为一个对象设置虚 引用关联的唯一目的只是为了能在这个对象被收集器回收时收到一个系统通知。在JDK 1.2版之后提供 了PhantomReference类来实现虚引用。

线程池的三大方法、七大参数和四种拒绝策略

这个面试也是必问的,在之前的文章中已经详细讲到了,这里就不再赘述

JUC并发编程系列(三)

CountDownLatch

CyclicBarrier

Semaphore

上面这三个常用辅助类我在之前的文章也讲过

JUC并发编程系列(二)