7.复习-Java多线程

Java多线程

1、线程的基本知识

并行与并发的区别

并行是同一时间能做多件事的能力,比如A核CPU同时执行4个线程

并发是同一时间应对多件事情的能力,多个线程轮流使用一个或多个CPU

并发的理解:宏观并行,微观串行

线程与进程的区别

  • 进程是正在运行程序的示例,进程中包含了线程,每个线程执行不同的任务
  • 不同的线程使用不同的内存空间,在当前进程下的所有线程可以共享内存空间
  • 线程更轻量,线程的上下文切换成本一般上要比进程低

java中创建线程的方式

4种常见的创建方式

1、继承Thread类

2、实现runnable接口

3、实现Callable接口

4、线程池创建线程

通常情况下项目中都会选择线程池的方式创建线程

runnable 和 callable 两个接口创建线程有什么不同呢?

1、最主要的区别是有无返回值

​ Runnable接口run方法无返回值

​ Callable接口call方法有返回值,是个泛型,和Future、FutureTask配合可以用来获取异步执行的结果

2、异常处理不同

​ Runnable接口run方法只能抛出运行时异常,也无法捕获处理

​ Callable接口call方法允许抛出异常,可以获取异常信息

在实际开发中,如果需要拿到执行的结果,需要使用Callalbe接口创建线程,调用FutureTask.get()得到可以得到返回值,此方法会阻塞主进程的继续往下执行,如果不调用不会阻塞。

1
2
3
4
5
6
7
8
运行时异常(RuntimeException)是一类特殊的异常,它们在程序运行时可能会抛出,并且通常不需要显式捕获或声明。这类异常通常表示程序中的逻辑错误,这些错误在编写程序时就应该避免。
常见的运行时异常类型
NullPointerException:尝试访问空对象的成员。
ArrayIndexOutOfBoundsException:尝试访问数组中不存在的索引。
IllegalArgumentException:传递给方法的参数无效。
ArithmeticException:算术运算异常,如除以零。
ClassCastException:尝试将对象强制转换为不兼容的类型。
NumberFormatException:解析数字字符串失败。

线程的生命周期

在JDK中的Thread类中的枚举State里面定义了6种线程的状态分别是:

新建(New)、可运行(Runnable)、终结(Terminated)

阻塞(Blocked)、等待(Waiting)和有时限等待(Timed Waiting)六种。

1、当一个线程对象被创建,但还未调用 start 方法时处于新建状态,调用了 start 方法,就会由新建进入可运行状态。

2、如果线程内代码已经执行完毕,由可运行进入终结状态。当然这些是一个线程正常执行情况。

3、如果线程获取锁失败后,由可运行进入 Monitor 的阻塞队列阻塞,只有当持锁线程释放锁时,会按照一定规则唤醒阻塞队列中的阻塞线程,唤醒后的线程进入可运行状态

4、如果线程获取锁成功后,但由于条件不满足,调用了 wait() 方法,此时从可运行状态释放锁等待状态,当其它持锁线程调用 notify() 或 notifyAll() 方法,会恢复为可运行状态

5、还有一种情况是调用 sleep(long) 方法也会从可运行状态进入有时限等待状态,不需要主动唤醒,超时时间到自然恢复为可运行状态

线程中的 wait 和 sleep方法有什么不同

它们两个的相同点是都可以让当前线程暂时放弃 CPU 的使用权,进入阻塞状态。

不同点主要有三个方面:

第一:方法归属不同

sleep(long) 是 Thread 的静态方法。而 wait(),是 Object 的成员方法,每个对象都有

第二:线程醒来时机不同

线程执行 sleep(long) 会在等待相应毫秒后醒来,而 wait() 需要被 notify 唤醒,wait() 如果不唤醒就一直等下去

第三:锁特性不同

wait 方法的调用必须先获取 wait 对象的锁,而 sleep 则无此限制

wait 方法执行后会释放对象锁,允许其它线程获得该对象锁(相当于我放弃 cpu,但你们还可以用)

而 sleep 如果在 synchronized 代码块中执行,并不会释放对象锁(相当于我放弃 cpu,你们也用不了)

新建 T1、T2、T3 三个线程,如何保证它们按顺序执行?

join()方法要做的事就是,当有新的线程加入时,主线程会进入等待状态,一直到调用join()方法的线程执行结束为止。

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
54
55
public class SequentialThreadsExample {

public static void main(String[] args) {
Thread t1 = new Thread(() -> {
System.out.println("T1 is running...");
try {
Thread.sleep(1000); // 模拟一些工作
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("T1 finished.");
});

Thread t2 = new Thread(() -> {
System.out.println("T2 is running...");
try {
Thread.sleep(1000); // 模拟一些工作
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("T2 finished.");
});

Thread t3 = new Thread(() -> {
System.out.println("T3 is running...");
try {
Thread.sleep(1000); // 模拟一些工作
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("T3 finished.");
});

t1.start();
try {
t1.join(); // 等待t1结束
} catch (InterruptedException e) {
e.printStackTrace();
}

t2.start();
try {
t2.join(); // 等待t2结束
} catch (InterruptedException e) {
e.printStackTrace();
}

t3.start();
try {
t3.join(); // 等待t3结束
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}

线程 run()和 start()的区别

  • start方法用来启动线程,通过该线程调用run方法执行run方法中所定义的逻辑代码。start方法只能被调用一次。
  • run方法封装了要被线程执行的代码,可以被调用多次。

如何停止一个正在运行的线程

第一:可以使用退出标志,使线程正常退出,也就是当run方法完成后线程终止,一般我们加一个标记

第二:可以使用线程的stop方法强行终止,不过一般不推荐,这个方法已作废

第三:可以使用线程的interrupt方法中断线程,内部其实也是使用中断标志来中断线程

建议使用第一种或第三种方式中断线程

2、线程中并发锁

synchronized关键字的底层原理

synchronized 底层使用的JVM级别中的Monitor 来决定当前线程是否获得了锁,如果某一个线程获得了锁,在没有释放锁之前,其他线程是不能或得到锁的。synchronized 属于悲观锁。

synchronized 因为需要依赖于JVM级别的Monitor ,相对性能也比较低。

Monitor

monitor对象存在于每个Java对象的对象头中,synchronized 锁便是通过这种方式获取锁的,也是为什么Java中任意对象可以作为锁的原因

monitor内部维护了三个变量

  • WaitSet:保存处于Waiting状态的线程

  • EntryList:保存处于Blocked状态的线程

  • Owner:持有锁的线程

只有一个线程获取到的标志就是在monitor中设置成功了Owner,一个monitor中只能有一个Owner

在上锁的过程中,如果有其他线程也来抢锁,则进入EntryList 进行阻塞,当获得锁的线程执行完了,释放了锁,就会唤醒EntryList 中等待的线程竞争锁,竞争的时候是非公平的。

synchronized 的锁升级

Java中的synchronized有偏向锁、轻量级锁、重量级锁三种形式,分别对应了锁制备一个线程持有、不同线程交替持有锁、多线程竞争锁三种情况。

重量级锁:底层使用的Monitor实现,里面涉及到了用户态和内核态的切换、进程的上下文切换,成本极高,性能比较低。

轻量级锁:线程加锁的实践是错开的(没有竞争),可以使用轻量级锁来优化、轻量级锁修改了对象头的锁标签,相对重量级锁性能提高很多。每次修改都是 CAS 操作(见下文),保证原子性

偏向锁:一段很长的时间只被一个线程使用锁,可以使用偏向锁,第一次获得锁时会有一个CAS操作,之后该线程在获取锁,只需要判断mark word(对象头)中是否时自己的线程id即可,而不是开销相对较大的CAS命令

一旦锁发生了竞争,都会升级为重量级锁

ReentrantLock

synchronized它在高并发量的情况下,性能不高,我们可以采用ReentrantLock来加锁。

ReentrantLock是一个可重入锁,调用 lock 方 法获取了锁之后,再次调用 lock,是不会再阻塞,内部直接增加重入次数就行了,标识这个线程已经重复获取一把锁而不需要等待锁的释放。

ReentrantLock是属于juc包下的类,属于api层面的锁,跟synchronized一样,都是悲观锁。通过lock()用来获取锁,unlock()释放锁。

它的底层实现原理主要利用CAS+AQS队列来实现。它支持公平锁和非公平锁,两者的实现类似

构造方法接受一个可选的公平参数(默认非公平锁),当设置为true时,表示公平锁,否则为非公平锁。公平锁的效率往往没有非公平锁的效率高。

CAS和AQS

CAS的全称时: Compare and Swap(比较再交换),它体现的是一种乐观锁的思想,在无锁的状态下保证线程操作数据的原子性。

  • CAS使用到的地方很多:AQS框架、AtomicXXX类

  • 在操作共享变量的时候使用的自旋锁,效率上更高一些

  • CAS的底层是调用的Unsafe类中的方法,都是操作系统提供的,其他语言实现

AQS,其实就一个jdk提供的类AbstractQueuedSynchronizer,是阻塞式锁和相关的同步器工具的框架。

内部有一个属性 state 属性来表示资源的状态,默认state等于0,表示没有获取锁,state等于1的时候才标明获取到了锁。通过CAS机制设置 state 状态

在它的内部还提供了基于 FIFO 的等待队列,是一个双向列表,其中

  • tail 指向队列最后一个元素

  • head 指向队列中最久的一个元素

ReentrantLock底层的实现就是一个AQS

synchronized和Lock的区别

第一,语法层面

  • synchronized 是关键字,源码在 jvm 中,用 c++ 语言实现,退出同步代码块锁会自动释放
  • Lock 是接口,源码由 jdk 提供,用 java 语言实现,需要手动调用 unlock 方法释放锁

第二,功能层面

  • 二者均属于悲观锁、都具备基本的互斥、同步、锁重入功能
  • Lock 提供了许多 synchronized 不具备的功能,例如获取等待状态、公平锁、可打断、可超时、多条件变量。

第三,性能层面

  • 在没有竞争时,synchronized 做了很多优化,如偏向锁、轻量级锁,性能不赖
  • 在竞争激烈时,Lock 的实现通常会提供更好的性能

统合来看,需要根据不同的场景来选择不同的锁的使用。

死锁产生的条件

t1 线程获得A对象锁,接下来想获取B对象的锁

t2 线程获得B对象锁,接下来想获取A对象的锁

这个时候t1线程和t2线程都在互相等待对方的锁,就产生了死锁

进行死锁诊断

可以通过jdk自动的工具来实现

1、先通过 jps 来查看当前java程序运行的进程id

2、然后通过 jstack 来查看这个进程id,就可以展示死锁的问题,并且可以定位代码的具体行号范围。

volatile 的理解

volatile 是一个关键字,可以修饰类的成员变量、类的静态成员变量,主要有两个功能

第一:保证了不同线程对这个变量进行操作时的可见性,即一个线程修改了某个变量的值,这个新值对其他线程来说是可以立即可见的,volatile关键字会强制将修改的值立即写入主存。

第二:禁止进行指令重排序,可以保证代码执行有序性。底层实现原理是,添加一个内存屏障,通过插入内存屏障精致内存屏障前后的指令执行重排序优化。

  • 写操作加的屏障是阻止上方其它写操作越过屏障排到volatile变量写之下
  • 读操作加的屏障是阻止下方其它读操作越过屏障排到volatile变量读之上

所以,现在我们就可以总结一个volatile使用的:

  • 写变量让volatile修饰的变量的在代码最后位置
  • 读变量让volatile修饰的变量的在代码最开始位置

ConcurrentHashMap的原理

ConcurrentHashMap 是一种线程安全的高效Map集合,jdk1.7和1.8也做了很多调整。

  • JDK1.7的底层采用是分段的数组+链表 实现
  • JDK1.8 采用的数据结构跟HashMap1.8的结构一样,数组+链表/红黑二叉树。

​ 在jdk1.7中 ConcurrentHashMap 里包含一个 Segment 数组。Segment 的结构和HashMap类似,是一 种数组和链表结构,一个 Segment 包含一个 HashEntry 数组每个 HashEntry 是一个链表结构 的元素,每个 Segment 守护着一个HashEntry数组里的元素,当对 HashEntry 数组的数据进行修 改时,必须首先获得对应的 Segment的锁。

​ Segment 是一种可重入的锁 ReentrantLock,每个 Segment 守护一个HashEntry 数组里得元 素,当对 HashEntry 数组的数据进行修改时,必须首先获得对应的 Segment 锁

​ 在jdk1.8中的ConcurrentHashMap 做了较大的优化,性能提升了不少。首先是它的数据结构与jdk1.8的hashMap数据结构完全一致。其次是放弃了Segment臃肿的设计,取而代之的是采用Node + CAS + Synchronized来保 证并发安全进行实现,synchronized只锁定当前链表或红黑二叉树的首节点,这样只要hash不冲 突,就不会产生并发 , 效率得到提升

3、线程池

线程池的核心参数

在线程池中一共有7个核心参数:

  1. corePoolSize 核心线程数目 - 池中会保留的最多线程数

  2. maximumPoolSize 最大线程数目 - 核心线程+救急线程的最大数目

  3. keepAliveTime 生存时间 - 救急线程的生存时间,生存时间内没有新任务,此线程资源会释放

  4. unit 时间单位 - 救急线程的生存时间单位,如秒、毫秒等

  5. workQueue - 当没有空闲核心线程时,新来任务会加入到此队列排队,队列满会创建救急线程执行任务

  6. threadFactory 线程工厂 - 可以定制线程对象的创建,例如设置线程名字、是否是守护线程等

  7. handler 拒绝策略 - 当所有线程都在繁忙,workQueue 也放满时,会触发拒绝策略

    • 在拒绝策略中又有4中拒绝策略

    • 当线程数过多以后,第一种是抛异常、第二种是由调用者执行任务、第三是丢弃当前的任务,第四是丢弃最早排队任务。默认是直接抛异常。

线程池的种类

jdk中默认提供了4种方式创建线程池

第一个是:newCachedThreadPool创建一个可缓存线程池,如果线程池长度超过处理需要,可灵活回收空闲线程,若无可回收,则新建线程。

  • 核心线程数为0
  • 最大线程数是Integer.MAX_VALUE
  • 适用场景:适合任务数比较密集,但每个任务执行时间较短的情况

第二个是:newFixedThreadPool 创建一个定长线程池,可控制线程最大并发数,超出的线程会在队列 中等待。

  • 核心线程数与最大线程数一样,没有救急线程
  • 请求队列最大长度为 Integer.MAX_VALUE
  • 适用场景:适用于任务量已知,相对耗时的任务

第三个是:newScheduledThreadPool 创建一个定长线程池,支持定时及周期性任务执行。

  • 适用场景:有定时和延迟执行的任务

第四个是:newSingleThreadExecutor 创建一个单线程化的线程池,它只会用唯一的工作线程来执行任 务,保证所有任务按照指定顺序(FIFO, LIFO, 优先级)执行。

  • 核心线程数和最大线程数都是1
  • 请求队列最大长度为 Integer.MAX_VALUE
  • 适用场景:适用于按照顺序执行的任务

如何确定核心线程池数

根据公司规范或者具体情况,如压测情况

线程池的执行原理

1、任务在提交的时候,首先判断核心线程数是否已满,如果没有满则直接添加到工作线程执行

2、如果核心线程数满了,则判断阻塞队列是否已满,如果没有满,当前任务存入阻塞队列

3、如果阻塞队列也满了,则判断线程数是否小于最大线程数,如果满足条件,则使用临时线程执行任务

4、如果核心或临时线程执行完成任务后会检查阻塞队列中是否有需要执行的线程,如果有,则使用非核心线程执行任务

5、如果所有线程都在忙着(核心线程+临时线程),则走拒绝策略

不建议使用Executors创建线程池

主要原因如下

  • newFixedThreadPool 和 newSingleThreadExecutor 使用的是无界队列(通常为LinkedBlockingQueue),这意味着如果生产任务的速度超过消费速度,队列会无限增长,最终可能导致内存耗尽(Out Of Memory Error)。
    • newCachedThreadPool 创建的是一个线程数量无界的线程池,当大量短期异步任务提交时,可能会迅速创建大量线程,消耗过多系统资源。

一般推荐使用ThreadPoolExecutor 来创建线程池,这样可以明确规定线程池的参数,避免资源的耗尽。

其他

ThreadLocal功能

ThreadLocal 主要功能有两个

  • 第一个是可以实现资源对象的线程隔离,让每个线程各用各的资源对象,避免争用引发的线程安全问题
  • 第二个是实现了线程内的资源共享

ThreadLocal的底层原理

ThreadLocal内部维护了一个一个 ThreadLocalMap 类型的成员变量,用来存储资源对象

当我们调用 set 方法,就是以 ThreadLocal 自己作为 key,资源对象作为 value,放入当前线程的 ThreadLocalMap 集合中

当调用 get 方法,就是以 ThreadLocal 自己作为 key,到当前线程中查找关联的资源值

当调用 remove 方法,就是以 ThreadLocal 自己作为 key,移除当前线程关联的资源值

关于ThreadLocal会导致内存溢出

因为 ThreadLocalMap 中的 key 被设计为弱引用,它是被动的被GC调用释放key,不过关键的是只有key可以得到内存释放,而value不会,因为value是一个强引用。

在使用ThreadLocal 时都把它作为静态变量(即强引用),因此无法被动依靠 GC 回收,建议主动的remove 释放 key,这样就能避免内存溢出。