多线程

0.并发编程的3个重要特性

Java并发编程三大特性

  • 原子性
  • 可见性
  • 有序性

(1)原子性

一个线程在CPU中操作不可暂停,也不可中断,要不执行完成,要不不执行

若不保证原子性,可能出现订单超卖问题

解决方案:

1.synchronized:同步加锁

2.JUC里面的lock:加锁

(2)内存可见性

内存可见性:让一个线程对共享变量的修改对另一个线程可见

解决方案:

  • synchronized
  • volatile(推荐)
  • LOCK

(3)有序性

指令重排:处理器为了提高程序运行效率,可能会对输入代码进行优化,它不保证程序中各个语句的执行先后顺序同代码中的顺序一致,但是它会保证程序最终执行结果和代码顺序执行的结果是一致的

解决方案:

  • volatile

1.线程进程

0.Thread的join()方法

1.join()的作用

join()是 Thread 类中的一个方法,当我们需要让线程按照自己指定的顺序执行的时候,就可以利用这个方法。Thread.join()方法表示调用此方法的线程被阻塞,仅当该方法完成以后,才能继续运行」

❝ 作用于 main( )主线程时,会等待其他线程结束后再结束主线程。 ❞

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
public static void main(String[] args){
System.out.println("MainThread run start.");

//启动一个子线程
Thread threadA = new Thread(new Runnable() {
@Override
public void run() {
System.out.println("threadA run start.");
try {
Thread.sleep(1000);
} catch (Exception e) {
e.printStackTrace();
}
System.out.println("threadA run finished.");
}
});
//没加join()的情况
threadA.start();

System.out.println("MainThread join before");
System.out.println("MainThread run finished.");
}
//输出
MainThread run start.
threadA run start.
MainThread join before
MainThread run finished.
threadA run finished.
因为上述子线程执行时间相对较长,所以是在主线程执行完毕之后才结束。
//加上join()方法的情况
threadA.start();

System.out.println("MainThread join before");
try {
threadA.join(); //调用join()
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("MainThread run finished.");
//输出
MainThread run start.
threadA run start.
MainThread join before
threadA run finished.
MainThread run finished.
对子线程threadA使用了join()方法之后,我们发现主线程会等待子线程执行完成之后才往后执行。

2.join()的原理

首先join() 是一个synchronized方法, 里面调用了wait(),这个过程的目的是让持有这个同步锁的线程进入等待,那么谁持有了这个同步锁呢?答案是主线程,因为主线程调用了threadA.join()方法,相当于在threadA.join()代码这块写了一个同步代码块,谁去执行了这段代码呢,是主线程,所以主线程被wait()了。然后在子线程threadA执行完毕之后,JVM会调用lock.notify_all(thread);唤醒持有threadA这个对象锁的线程,也就是主线程,会继续执行。

1. 线程和进程是什么及其区别?

什么是进程?

进程是程序的一次执行过程,是系统运行程序的基本单位,因此进程是动态的。系统运行一个程序即是一个进程从创建,运行到消亡的过程。在 Windows 中通过查看任务管理器的方式,我们就可以清楚看到 Windows 当前运行的进程(.exe 文件的运行)。当一个程序被运行,从磁盘加载这个程序的代码至内存,这时就开启了一个进程。

什么是线程?

一个进程之内可以分为一到多个线程。

一个线程就是一个指令流,将指令流中的一条条指令以一定的顺序交给 CPU 执行

Java 中,线程作为最小调度单位,进程作为资源分配的最小单位。

二者对比

  • 进程是正在运行程序的实例,进程中包含了线程,每个线程执行不同的任务
  • 不同的进程使用不同的内存空间,在当前进程下的所有线程可以共享内存空间
  • 线程更轻量,线程上下文切换成本一般上要比进程上下文切换低(上下文切换指的是从一个线程切换到另一个线程)
  • 线程作为最小调度单位,进程作为资源分配的最小单位。

2 并行和并发有什么区别?

  • 并发:两个及两个以上的作业在同一 时间段 内执行。
  • 并行:两个及两个以上的作业在同一 时刻 执行。

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

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

3.同步和异步的区别

  • 同步:发出一个调用之后,在没有得到结果之前, 该调用就不可以返回,一直等待。
  • 异步:调用在发出之后,不用等待返回结果,该调用直接返回。

3 创建线程的四种方式

共有四种方式可以创建线程,分别是:继承Thread类、实现runnable接口、实现Callable接口、线程池创建线程

详细创建方式参考下面代码:

继承Thread类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public class MyThread extends Thread {

@Override
public void run() {
System.out.println("MyThread...run...");
}


public static void main(String[] args) {

// 创建MyThread对象
MyThread t1 = new MyThread() ;
MyThread t2 = new MyThread() ;

// 调用start方法启动线程
t1.start();
t2.start();

}

}

实现runnable接口

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

@Override
public void run() {
System.out.println("MyRunnable...run...");
}

public static void main(String[] args) {

// 创建MyRunnable对象
MyRunnable mr = new MyRunnable() ;

// 创建Thread对象
Thread t1 = new Thread(mr) ;
Thread t2 = new Thread(mr) ;

// 调用start方法启动线程
t1.start();
t2.start();

}

}

实现Callable接口

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
public class MyCallable implements Callable<String> {
//MyCallable类实现了Callable<String>接口,该接口是一个泛型接口,指定了call()方法的返回类型为String
@Override
public String call() throws Exception {//call方法可以抛出异常
System.out.println("MyCallable...call...");
return "OK";
}

public static void main(String[] args) throws ExecutionException, InterruptedException {

// 创建MyCallable对象
MyCallable mc = new MyCallable() ;

//创建FutureTask<String>对象ft,并将mc作为参数传入构造方法,用于封装可调用对象
FutureTask<String> ft = new FutureTask<String>(mc) ;
//通过将Callable对象封装在FutureTask中,可以在多线程环境下执行任务,并获取任务执行结果。

// 创建Thread对象
Thread t1 = new Thread(ft) ;
Thread t2 = new Thread(ft) ;

// 调用start方法启动线程
t1.start();

//调用ft.get()方法获取执行结果,此方法会阻塞当前线程直到结果返回。
String result = ft.get();

// 输出
System.out.println(result);

}
}
//输出结果
MyCallable...call...
OK

线程池创建线程

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public class MyExecutors implements Runnable{

@Override
public void run() {
System.out.println("MyRunnable...run...");
}

public static void main(String[] args) {

// 创建线程池对象。获取ExecutorService实例,生产禁用,需要手动创建线程池
ExecutorService threadPool = Executors.newFixedThreadPool(3);
//通过threadPool.submit(new MyExecutors())来向线程池提交任务。
threadPool.submit(new MyExecutors()) ;

//调用 Executor 的 shutdown() 方法会等待线程都执行完毕之后再关闭,但是如果调用的是 shutdownNow() 方法,则相当于调用每个线程的 interrupt() 方法。
threadPool.shutdown();

}

}

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

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

还有一个就是,他们异常处理也不一样。Runnable接口run方法只能抛出运行时异常,也无法捕获处理;Callable接口call方法允许抛出异常,可以获取异常信息

4. 线程的 run()和 start()有什么区别?

start(): 用来启动线程,通过该线程调用run方法执行run方法中所定义的逻辑代码。start方法只能被调用一次。

run(): 封装了要被线程执行的代码,可以被调用多次。

可以直接调用 Thread 类的 run 方法吗?

new 一个 Thread,线程进入了新建状态。调用 start()方法,会启动一个线程并使线程进入了就绪状态,当分配到时间片后就可以开始运行了。 start() 会执行线程的相应准备工作,然后自动执行 run() 方法的内容,这是真正的多线程工作。 但是,直接执行 run() 方法,会把 run() 方法当成一个 main 线程下的普通方法去执行,并不会在某个线程中执行它,所以这并不是多线程工作。

总结:调用 start() 方法方可启动线程并使线程进入就绪状态,直接执行 run() 方法的话不会以多线程的方式执行。

5.线程的生命周期和状态

**初始(NEW)**:线程被构建,还没有调用 start()。

**运行(RUNNABLE)**:包括操作系统的就绪和运行两种状态。

**阻塞(BLOCKED)**:一般是被动的,在抢占资源中得不到资源,被动的挂起在内存,等待资源释放将其唤醒。线程被阻塞会释放CPU,不释放内存。

**等待(WAITING)**:进入该状态的线程需要等待其他线程做出一些特定动作(通知或中断)。

**超时等待(TIMED_WAITING)**:该状态不同于WAITING,它可以在指定的时间后自行返回。

**终止(TERMINATED)**:表示该线程已经执行完毕。

6 线程状态之间是如何变化的

当一个线程对象被创建,但还未调用 start 方法时处于新建状态,调用了 start 方法,就会由新建进入可运行状态。如果线程内代码已经执行完毕,由可运行进入终结状态。当然这些是一个线程正常执行情况。

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

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

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

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

可以用线程类的join()方法在一个线程中启动另一个线程,另外一个线程完成该线程继续执行。

代码举例:

为了确保三个线程的顺序你应该先启动最后一个(T3调用T2,T2调用T1),这样T1就会先完成而T3最后完成

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
public class JoinTest {

public static void main(String[] args) {

// 创建线程对象
Thread t1 = new Thread(() -> {
System.out.println("t1");
}) ;

Thread t2 = new Thread(() -> {
try {
t1.join(); // 加入线程t1,只有t1线程执行完毕以后,再次执行该线程
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("t2");
}) ;


Thread t3 = new Thread(() -> {
try {
t2.join(); // 加入线程t2,只有t2线程执行完毕以后,再次执行该线程
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("t3");
}) ;

// 启动线程
t1.start();
t2.start();
t3.start();

}

}

7.什么是线程上下文切换?

线程在执行过程中会有自己的运行条件和状态(也称上下文),比如上文所说到过的程序计数器,栈信息等。当出现如下情况的时候,线程会从占用 CPU 状态中退出。

  • 主动让出 CPU,比如调用了 sleep(), wait() 等。
  • 时间片用完,因为操作系统要防止一个线程或者进程长时间占用 CPU 导致其他线程或者进程饿死。
  • 调用了阻塞类型的系统中断,比如请求 IO,线程被阻塞。
  • 被终止或结束运行

这其中前三种都会发生线程切换,线程切换意味着需要保存当前线程的上下文,留待线程下次占用 CPU 的时候恢复现场。并加载下一个将要占用 CPU 的线程上下文。这就是所谓的 上下文切换

8.线程死锁

0.什么是线程死锁?

死锁:线程死锁是指两个或两个以上的线程在执行过程中,因争夺资源而造成的一种互相等待的现象。若无外力作用,它们都将无法推进下去。

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
package com.itheima.basic;

import static java.lang.Thread.sleep;

public class Deadlock {
//t1 线程获得A资源,接下来想获取B资源
//t2 线程获得B资源,接下来想获取A资源
public static void main(String[] args) {
Object A = new Object();
Object B = new Object();
Thread t1 = new Thread(() -> {
synchronized (A) {
System.out.println(Thread.currentThread() + "get resource1");
try {
sleep(1000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
synchronized (B) {
System.out.println(Thread.currentThread() + "get resource1");

}
}
}, "t1");

Thread t2 = new Thread(() -> {
synchronized (B) {
System.out.println(Thread.currentThread() + "get resource1");
try {
sleep(500);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
synchronized (A) {
System.out.println(Thread.currentThread() + "get resource1");

}
}
}, "t2");
t1.start();
t2.start();
}
}

1.线程死锁怎么产生?怎么避免?

死锁产生的四个必要条件

  • 互斥:一个资源每次只能被一个进程使用
  • 请求与保持:一个进程因请求资源而阻塞时,不释放获得的资源
  • 不剥夺:进程已获得的资源,在未使用之前,不能强行剥夺
  • 循环等待:进程之间循环等待着资源

避免死锁的方法

  • 互斥条件不能破坏,因为加锁就是为了保证互斥
  • 一次性申请所有的资源,避免线程占有资源而且在等待其他资源
  • 占有部分资源的线程进一步申请其他资源时,如果申请不到,主动释放它占有的资源
  • 按序申请资源

2.如何进行死锁诊断?

通过jdk自动的工具就能搞定

我们可以先通过jps来查看当前java程序运行的进程id

然后通过jstack来查看这个进程id,就能展示出来死锁的问题,并且,可以定位代码的具体行号范围,我们再去找到对应的代码进行排查就行了。

9. notify()和 notifyAll()有什么区别?

notifyAll:唤醒所有wait的线程

notify:只随机唤醒一个 wait 线程

10. 在 java 中 wait 和 sleep 方法的不同?

共同点

  • wait() ,wait(long) 和 sleep(long) 的效果都是让当前线程暂时放弃 CPU 的使用权,进入阻塞状态
  • 任何线程在调用wait()和sleep()之后,在等待期间被中断都会抛出InterruptedException

不同点

  • 方法归属不同
    • sleep(long) 是 Thread 的静态方法
    • 而 wait(),wait(long) 都是 Object 的成员方法,每个对象都有
  • 醒来时机不同
    • 执行 sleep(long) 和 wait(long) 的线程都会在等待相应毫秒后醒来
    • wait(long) 和 wait() 还可以被 notify 唤醒,wait() 如果不唤醒就一直等下去
    • 它们都可以被打断唤醒
  • 锁特性不同(重点)
    • wait 方法的调用必须先获取 wait 对象的锁,而 sleep 则无此限制
    • wait 方法执行后会释放对象锁,允许其它线程获得该对象锁(我放弃 cpu,但你们还可以用)
    • 而 sleep 如果在 synchronized 代码块中执行,并不会释放对象锁(我放弃 cpu,你们也用不了)

代码示例:

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
public class WaitSleepCase {

static final Object LOCK = new Object();

public static void main(String[] args) throws InterruptedException {
sleeping();
}

private static void illegalWait() throws InterruptedException {
LOCK.wait();
}

private static void waiting() throws InterruptedException {
Thread t1 = new Thread(() -> {
synchronized (LOCK) {
try {
get("t").debug("waiting...");
LOCK.wait(5000L);
} catch (InterruptedException e) {
get("t").debug("interrupted...");
e.printStackTrace();
}
}
}, "t1");
t1.start();

Thread.sleep(100);
synchronized (LOCK) {
main.debug("other...");
}

}

private static void sleeping() throws InterruptedException {
Thread t1 = new Thread(() -> {
synchronized (LOCK) {
try {
get("t").debug("sleeping...");
Thread.sleep(5000L);
} catch (InterruptedException e) {
get("t").debug("interrupted...");
e.printStackTrace();
}
}
}, "t1");
t1.start();

Thread.sleep(100);
synchronized (LOCK) {
main.debug("other...");
}
}
}

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

有3种方式可以停止线程

  • 使用stop方法
  • 使用退出标志,使线程正常退出,也就是当run方法完成后线程终止
  • 使用interrupt方法中断线程

代码参考如下:

0.使用stop方法

虽然Thread类提供了一个stop()方法来强行终止线程,但这个方法已经被弃用,因为它是不安全的。使用stop()方法可能会导致资源无法正确释放,或者导致应用程序状态不一致。

1.使用退出标志,使线程正常退出

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
public class MyInterrupt1 extends Thread {

volatile boolean flag = false ; // 线程执行的退出标记

@Override
public void run() {
while(!flag) {
System.out.println("MyThread...run...");
try {
Thread.sleep(3000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}

public static void main(String[] args) throws InterruptedException {

// 创建MyThread对象
MyInterrupt1 t1 = new MyInterrupt1() ;
t1.start();

// 主线程休眠6秒
Thread.sleep(6000);

// 更改标记为true
t1.flag = true ;

}
}

2.使用interrupt方法中断线程

通过调用一个线程的 interrupt() 来中断该线程,如果该线程处于阻塞、限期等待或者无限期等待状态,那么就会抛出 InterruptedException,从而提前结束该线程。但是不能中断 I/O 阻塞和 synchronized 锁阻塞。

对于以下代码,在 main() 中启动一个线程之后再中断它,由于线程中调用了 Thread.sleep() 方法,因此会抛出一个 InterruptedException,从而提前结束线程,不执行之后的语句。

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
public class InterruptExample {

private static class MyThread1 extends Thread {
@Override
public void run() {
try {
Thread.sleep(2000);
System.out.println("Thread run");
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
public static void main(String[] args) throws InterruptedException {
Thread thread1 = new MyThread1();
thread1.start();
thread1.interrupt();
System.out.println("Main run");
}
//输出结果
Main run
java.lang.InterruptedException: sleep interrupted
at java.lang.Thread.sleep(Native Method)
at InterruptExample.lambda$main$0(InterruptExample.java:5)
at InterruptExample$$Lambda$1/713338599.run(Unknown Source)
at java.lang.Thread.run(Thread.java:745)

interrupted()

如果一个线程的 run() 方法执行一个无限循环,并且没有执行 sleep() 等会抛出 InterruptedException 的操作,那么调用线程的 interrupt() 方法就无法使线程提前结束。

但是调用 interrupt() 方法会设置线程的中断标记,此时调用 interrupted() 方法会返回 true。因此可以在循环体中使用 interrupted() 方法来判断线程是否处于中断状态,从而提前结束线程。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class InterruptExample {

private static class MyThread2 extends Thread {
@Override
public void run() {
while (!Thread.currentThread().interrupted()) {
// ..
}
System.out.println("Thread end");
}
}
}
public static void main(String[] args) throws InterruptedException {
Thread thread2 = new MyThread2();
thread2.start();
thread2.interrupt();
}
//输出结果
Thread end

12.线程间通信方式

1、使用 Object 类的 **wait()/notify()**。Object 类提供了线程间通信的方法:wait()notify()notifyAll(),它们是多线程通信的基础。其中,wait/notify 必须配合 synchronized 使用,wait 方法释放锁,notify 方法不释放锁。wait 是指在一个已经进入了同步锁的线程内,让自己暂时让出同步锁,以便其他正在等待此锁的线程可以得到同步锁并运行,只有其他线程调用了notify(),notify并不释放锁,只是告诉调用过wait()的线程可以去参与获得锁的竞争了,但不是马上得到锁,因为锁还在别人手里,别人还没释放,调用 wait() 的一个或多个线程就会解除 wait 状态,重新参与竞争对象锁,程序如果可以再次得到锁,就可以继续向下运行。

2、使用 volatile 关键字。基于volatile关键字实现线程间相互通信,其底层使用了共享内存。简单来说,就是多个线程同时监听一个变量,当这个变量发生变化的时候 ,线程能够感知并执行相应的业务。

3、使用JUC工具类 CountDownLatch。jdk1.5 之后在java.util.concurrent包下提供了很多并发编程相关的工具类,简化了并发编程开发,CountDownLatch 基于 AQS 框架,相当于也是维护了一个线程间共享变量 state。

4、基于 LockSupport 实现线程间的阻塞和唤醒。LockSupport 是一种非常灵活的实现线程间阻塞和唤醒的工具,使用它不用关注是等待线程先进行还是唤醒线程先运行,但是得知道线程的名字

13.进程间的通讯方式

进程间通信(IPC,InterProcess Communication)是指在不同进程之间传播或交换信息。Linux 内核提供了多种进程间通信机制,包括管道、消息队列、共享内存、信号量和 PV 操作、SysV 和 POSIX 信号量以及套接字(Socket)。

  • 管道:管道是一种半双工的通信方式,数据只能单向流动,而且只能在具有亲缘关系的进程间使用。进程的亲缘关系通常是指父子进程关系。
  • 消息队列:消息队列是由消息的链表,存放在内核中并由消息队列标识符标识。消息队列克服了信号传递信息少、管道只能承载无格式字节流以及缓冲区大小受限等缺点。
  • 共享内存:共享内存就是映射一段能被其他进程所访问的内存,这段内存由一个进程创建,但多个进程都可以访问。共享内存是最快的 IPC 方式,它可以被用来实现高速数据传输和大量数据的传输,因此它往往用于进程间的数据共享或者是进程间的通信。
  • 信号量和 PV 操作:主要作为互斥锁。
  • SysV 和 POSIX 信号量:包括计数器和 semaphore 结构体。
  • 套接字(Socket):主要用于不同主机间的进程通信。

2.线程中并发锁

1. 锁的分类

-1.可中断锁和不可中断锁

  • 可中断锁:获取锁的过程中可以被中断,不需要一直等到获取锁之后 才能进行其他逻辑处理。ReentrantLock 就属于是可中断锁。
  • 不可中断锁:一旦线程申请了锁,就只能等到拿到锁以后才能进行其他的逻辑处理。 synchronized 就属于是不可中断锁。

0.可重入锁

可重入锁 也叫递归锁,指的是线程可以再次获取自己的内部锁。比如一个线程获得了某个对象的锁,此时这个对象锁还没有释放,当其再次想要获取这个对象的锁的时候还是可以获取的,如果是不可重入锁的话,就会造成死锁。

JDK 提供的所有现成的 Lock 实现类,包括 synchronized 关键字锁都是可重入的。

1. 公平锁与非公平锁

公平锁:多个线程按照申请锁的顺序去获得锁,线程会直接进入队列去排队,永远都是队列的第一位才能得到锁。

非公平锁:多个线程去获取锁的时候,会直接去尝试获取,获取不到,再去进入等待队列,如果能获取到,就直接获取到锁。

synchronized是非公平锁,Lock默认是非公平锁,可以设置为公平锁,公平锁会影响性能。

2. 共享式与独占式锁

  • 共享锁:一把锁可以被多个线程同时获得。比如semaphore信号量
  • 独占锁:一把锁只能被一个线程获得。比如sychronized,reentrantlock

3.悲观锁与乐观锁

1.两者的区别

悲观锁总是假设最坏的情况,认为共享资源每次被访问的时候就会出现问题(比如共享数据被修改),所以每次在获取资源操作的时候都会上锁,这样其他线程想拿到这个资源就会阻塞直到锁被上一个持有者释放。也就是说,共享资源每次只给一个线程使用,其它线程阻塞,用完后再把资源转让给其它线程

乐观锁总是假设最好的情况,认为共享资源每次被访问的时候不会出现问题,线程可以不停地执行,无需加锁也无需等待,只是在提交修改的时候去验证数据是否被其它线程修改。乐观锁最常见的实现就是CAS

适用场景:

  • 悲观锁适合写操作多的场景。
  • 乐观锁适合读操作多的场景,不加锁可以提升读操作的性能。

2.乐观锁是怎么实现的?

可以用版本号机制或CAS算法。

版本号机制

一般是在数据表中加上一个数据版本号 version 字段,表示数据被修改的次数。当数据被修改时,version 值会加一。当线程 A 要更新数据值时,在读取数据的同时也会读取 version 值,在提交更新时,若刚才读取到的 version 值为当前数据库中的 version 值相等时才更新,否则重试更新操作,直到更新成功。

3.什么是CAS

CAS 的全称是 Compare And Swap(比较与交换) ,用于实现乐观锁,被广泛应用于各大框架中。CAS 的思想很简单,就是用一个预期值和要更新的变量值进行比较,两值相等才会进行更新。

CAS 是一个原子操作,底层依赖于一条 CPU 的原子指令。

原子操作 即最小不可拆分的操作,也就是说操作一旦开始,就不能被打断,直到操作完成。

CAS 涉及到三个操作数:

  • V:要更新的变量值(Var)
  • E:预期值(Expected)
  • N:拟写入的新值(New)

当且仅当 V 的值等于 E 时,CAS 通过原子方式用新值 N 来更新 V 的值。如果不等,说明已经有其它线程更新了 V,则当前线程放弃更新。

4. CAS的自旋

自旋: 就是不停的判断比较,看能否将值交换

我们都知道,多个线程在访问共享资源的时候,会产生同步问题,所以需要加锁来保证安全。但是,一旦加了锁,同一时刻只能有一个线程获取锁对象,效率自然变低了。

那在多线程场景下,不加锁的情况下来修改值,CAS是怎么自旋的呢?

现在Data中存放的是num=0,线程A将num=0拷贝到自己的工作内存中计算(做+1操作)E=0,计算的结果为V=1 由于是在多线程不加锁的场景下操作,所以可能此时num会被别的线程修改为其他值。此时需要再次读取num看其是否被修改,记再次读取的值为N 如果被修改,即E != N,说明被其他线程修改过。那么此时工作内存中的E已经和主存中的num不一致了,根据EMSI协议,保证安全需要重新读取num的值。直到E = N才能修改 如果没被修改,即E = N,说明没被其他线程修改过。那门将工作内存中的E=0改为E=1,同时写回主存。将num=0改为num=1

5.CAS/乐观锁存在的问题(ABA)?

CAS 三大问题:

  1. ABA问题。CAS需要在操作值的时候检查内存值是否发生变化,没有发生变化才会更新内存值。但是如果内存值原来是A,后来变成了B,然后又变成了A,那么CAS进行检查时会发现值没有发生变化,但是实际上是有变化的。ABA问题的解决思路就是在变量前面添加版本号,每次变量更新的时候都把版本号加一,这样变化过程就从A-B-A变成了1A-2B-3A

    JDK从1.5开始提供了AtomicStampedReference类来解决ABA问题,原子更新带有版本号的引用类型。

  2. 循环时间长开销大。CAS操作如果长时间不成功,会导致其一直自旋,给CPU带来非常大的开销。

  3. 只能保证一个共享变量的原子操作。对一个共享变量执行操作时,CAS能够保证原子操作,但是对多个共享变量操作时,CAS是无法保证操作的原子性的。

2.synchronized

0.synchronized的作用有哪些?

原子性:确保线程互斥的访问同步代码;

可见性:保证共享变量的修改能够及时可见;

有序性:有效解决重排序问题。

1.synchronized的用法有哪些?

  1. 修饰普通方法:作用于当前对象实例,进入同步代码前要获得当前对象实例的锁
  2. 修饰静态方法:作用于当前类,进入同步代码前要获得当前类对象的锁,synchronized关键字加到static 静态方法和 synchronized(class)代码块上都是是给 Class 类上锁
  3. 修饰代码块:指定加锁对象,对给定对象加锁,进入同步代码库前要获得给定对象的锁

2.synchronized解决抢票超卖问题

如下抢票的代码,如果不加锁,就会出现超卖或者一张票卖给多个人

Synchronized【对象锁】采用互斥的方式让同一时刻至多只有一个线程能持有【对象锁】,其它线程再想获取这个【对象锁】时就会阻塞住

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
public class TicketDemo {

static Object lock = new Object();
int ticketNum = 10;


public synchronized void getTicket() {
synchronized (this) {
if (ticketNum <= 0) {
return;
}
System.out.println(Thread.currentThread().getName() + "抢到一张票,剩余:" + ticketNum);
// 非原子性操作
ticketNum--;
}
}

public static void main(String[] args) {
TicketDemo ticketDemo = new TicketDemo();
for (int i = 0; i < 20; i++) {
new Thread(() -> {
ticketDemo.getTicket();
}).start();
}
}


}

3. 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 中等待的线程竞争锁,竞争的时候是非公平的。

4.synchronized 的锁升级

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

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

轻量级锁:线程加锁的时间是错开的(也就是没有竞争),可以使用轻量级锁来优化。轻量级修改了对象头的锁标志,相对重量级锁性能提升很多。每次修改都是CAS操作,保证原子性

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

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

5. synchronized与volatile的区别

volatile只能使用在变量上;而synchronized可以在类,变量,方法和代码块上。

volatile至保证可见性;synchronized保证原子性与可见性。

volatile禁用指令重排序;synchronized不会。

volatile不会造成阻塞;synchronized

6.synchronized和Lock有什么区别 ?

第一,语法层面

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

第二,功能层面

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

第三,性能层面

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

3.volatile

1.volatile关键字的两个作用

  1. 保证了不同线程对共享变量进行操作时的可见性,即一个线程修改了某个变量的值,这新值对其他线程来说是立即可见的。
  2. 禁止进行指令重排序
1
指令重排序是JVM为了优化指令,提高程序运行效率,在不影响单线程程序执行结果的前提下,尽可能地提高并行度。Java编译器会在生成指令系列时在适当的位置会插入内存屏障指令来禁止处理器重排序。插入一个内存屏障,相当于告诉CPU和编译器先于这个命令的必须先执行,后于这个命令的必须后执行。对一个volatile字段进行写操作,Java内存模型将在写操作后插入一个写屏障指令,这个指令会把之前的写入值都刷新到内存

2.volatile底层原理

volatile是轻量级的同步机制,volatile保证变量对所有线程的可见性,不保证原子性。

  1. 当对volatile变量进行写操作的时候,JVM会向处理器发送一条LOCK前缀的指令,将该变量所在缓存行的数据写回系统内存。
  2. 由于缓存一致性协议,每个处理器通过嗅探在总线上传播的数据来检查自己的缓存是不是过期了,当处理器发现自己缓存行对应的内存地址被修改,就会将当前处理器的缓存行置为无效状态,当处理器对这个数据进行修改操作的时候,会重新从系统内存中把数据读到处理器缓存中。

来看看缓存一致性协议是什么。

缓存一致性协议:当CPU写数据时,如果发现操作的变量是共享变量,即在其他CPU中也存在该变量的副本,会发出信号通知其他CPU将该变量的缓存行置为无效状态,因此当其他CPU需要读取这个变量时,就会从内存重新读取。

3.volatile怎么保证线程间的可见性

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

为什么要保证线程间的可见性?

Java的内存模型中,每个线程会有一个私有本地内存的抽象概念,正常情况下线程操作普通共享变量时都会在本地内存修改和读取,那就导致别的线程感知不到,出现可见性问题。而当一个共享变量被volatile修饰时,它会保证修改的值会立即被更新到主内存,当有其他线程需要读取时,它也会去主内存中读取新值。这样就解决的可见性问题。

4.volatile为什么要禁止指令重排,能举一个具体的指令重排出现问题的例子吗?

1
指令重排序是JVM为了优化指令,提高程序运行效率,在不影响单线程程序执行结果的前提下,尽可能地提高并行度。Java编译器会在生成指令系列时在适当的位置会插入内存屏障指令来禁止处理器重排序。插入一个内存屏障,相当于告诉CPU和编译器先于这个命令的必须先执行,后于这个命令的必须后执行。对一个volatile字段进行写操作,Java内存模型将在写操作后插入一个写屏障指令,这个指令会把之前的写入值都刷新到内存

用 volatile 修饰共享变量会在读、写共享变量时加入不同的屏障,阻止其他读写操作越过屏障,从而达到阻止重排序的效果。

单例模式例子代码如下:

例子中,单例类实例对象uniqueInstance 采用 volatile 关键字修 饰也是很有必要的, uniqueInstance = new Singleton(); 这段代码其实是分为三步执行:

  1. 为 uniqueInstance 分配内存空间
  2. 初始化 uniqueInstance
  3. 将 uniqueInstance 指向分配的内存地址

但是由于 JVM 具有指令重排的特性,执行顺序有可能变成 1->3->2。指令重排在单线程环境下不会出现问题,但是在多线程环境下会导致一个线程获得还没有初始化的实例。例如,线程 T1 执行了 1 和 3,此时 T2 调用 getUniqueInstance() 后发现 uniqueInstance 不为空,因此返回 uniqueInstance,但此时 uniqueInstance 还未被初始化。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public class Singleton {

private volatile static Singleton uniqueInstance;

private Singleton() {
}

public static Singleton getUniqueInstance() {
//先判断对象是否已经实例过,没有实例化过才进入加锁代码
if (uniqueInstance == null) {
//类对象加锁
synchronized (Singleton.class) {
if (uniqueInstance == null) {
uniqueInstance = new Singleton();
}
}
}
return uniqueInstance;
}
}

5.volatile为什么不能保证原子性

volatile可以保证可见性和顺序性,但是它不能保证原子性。

举个例子。一个变量i被volatile修饰,两个线程想对这个变量修改,都对其进行自增操作i++,i++的过程可以分为三步,首先获取i的值,其次对i的值进行加1,最后将得到的新值写会到缓存中。

假如i的初始值为100。线程A首先得到了i的初始值100,但是还没来得及修改,就阻塞了,这时线程B开始了,它也去取i的值,由于i的值未被修改,即使是被volatile修饰,主存的变量还没变化,那么线程B得到的值也是100,之后对其进行加1操作,得到101,将新值写入到缓存中,再刷入主存中。根据可见性的原则,这个主存的值可以被其他线程可见。

那么问题来了,线程A之前已经读取到了i的值为100,线程A阻塞结束后,继续将100这个值加1,得到101,再将值写到缓存,最后刷入主存。这样i经过两次自增之后,结果值只加了1,明显是有问题的。所以说即便volatile具有可见性,也不能保证对它修饰的变量具有原子性。

6.volatile和synchronized的区别是什么?

  1. volatile只能使用在变量上;而synchronized可以在类,变量,方法和代码块上。
  2. volatile至保证可见性;synchronized保证原子性与可见性。
  3. volatile禁用指令重排序;synchronized不会。
  4. volatile不会造成阻塞;synchronized会。

4.ReentrantLock

1.什么是ReentrantLock

ReentrantLock 实现了 Lock 接口,是一个可重入且独占式的锁,相对于synchronized它具备以下特点:

  • 可中断
  • 可以设置超时时间
  • 可以设置公平锁
  • 支持多个条件变量
  • 与synchronized一样,都支持重入

什么是可重入锁?

可重入锁 也叫递归锁,指的是线程可以再次获取自己的内部锁。比如一个线程获得了某个对象的锁,此时这个对象锁还没有释放,当其再次想要获取这个对象的锁的时候还是可以获取的,如果是不可重入锁的话,就会造成死锁。

JDK 提供的所有现成的 Lock 实现类,包括 synchronized 关键字锁都是可重入的。

2.实现原理

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

构造方法接受一个可选的公平参数(默认非公平锁),当设置为true时,表示公平锁,否则为非公平锁。公平锁的效率往往没有非公平锁的效率高,在许多线程访问的情况下,公平锁表现出较低的吞吐量。

提供了两个构造方法,不带参数的默认为非公平

如果使用带参数的构造函数,并且传的值为true,则是公平锁

其中NonfairSync和FairSync这两个类父类都是Sync

而Sync的父类是AQS,所以可以得出ReentrantLock底层主要实现就是基于AQS来实现的

工作流程

  • 线程来抢锁后使用cas的方式修改state状态,修改状态成功为1,则让exclusiveOwnerThread属性指向当前线程,获取锁成功
  • 假如修改状态失败,则会进入双向队列中等待,head指向双向队列头部,tail指向双向队列尾部
  • 当exclusiveOwnerThread为null的时候,则会唤醒在双向队列中等待的线程
  • 公平锁则体现在按照先后顺序获取锁,非公平体现在不在排队的线程也可以抢锁

3. synchronized和ReenTrantLock有什么异同 ?

相同点:两者都是可重入锁

不同点:

  1. 使用synchronized关键字实现同步,线程执行完同步代码块会自动释放锁,而ReentrantLock需要手动释放锁。
  2. synchronized是非公平锁,ReentrantLock可以设置为公平锁。
  3. ReentrantLock上等待获取锁的线程是可中断的,线程可以放弃等待锁。而synchonized会无限期等待下去。
  4. ReentrantLock 可以设置超时获取锁。在指定的截止时间之前获取锁,如果截止时间到了还没有获取到锁,则返回。
  5. ReentrantLock 的 tryLock() 方法可以尝试非阻塞的获取锁,调用该方法后立刻返回,如果能够获取则返回true,否则返回false。

4.ReentrantLock 是如何实现可重入性的?

ReentrantLock通过维护一个计数器来实现可重入性

以下是ReentrantLock实现可重入性的机制:

  1. 线程局部变量:当一个线程第一次请求锁时,ReentrantLock会记录这个线程,并将计数器设置为1。
  2. 计数器递增:如果同一个线程再次请求锁,ReentrantLock会检查当前线程是否已经是锁的持有者。如果是,计数器会增加,线程可以继续执行,不会被阻塞。
  3. 计数器递减:当线程释放锁时,计数器会递减。只有当计数器归零时,锁才真正被释放,其他等待的线程才有机会获取锁。

5.ReentrantLock支持公平和非公平两种锁

ReentrantLock支持公平和非公平两种锁模式。公平模式下,锁的获取顺序按照线程请求的顺序进行;非公平模式下,锁可以被任何请求它的线程获得,不考虑请求的顺序。

3.ThreadLocal

1.什么是ThreadLocal

ThreadLocal为每个线程提供单独一份存储空间,具有线程隔离的效果,只有在线程内才能获取到对应的值,线程外则不能访问。

2. ThreadLocal基本使用

三个主要方法:

  • set(value) 设置值
  • get() 获取值
  • remove() 清除值
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
public class ThreadLocalTest {
static ThreadLocal<String> threadLocal = new ThreadLocal<>();

public static void main(String[] args) {
new Thread(() -> {
String name = Thread.currentThread().getName();
threadLocal.set("itcast");
print(name);
System.out.println(name + "-after remove : " + threadLocal.get());
}, "t1").start();
new Thread(() -> {
String name = Thread.currentThread().getName();
threadLocal.set("itheima");
print(name);
System.out.println(name + "-after remove : " + threadLocal.get());
}, "t2").start();
}

static void print(String str) {
//打印当前线程中本地内存中本地变量的值
System.out.println(str + " :" + threadLocal.get());
//清除本地内存中的本地变量
threadLocal.remove();
}

}

3 ThreadLocal的实现原理

在ThreadLocal中有一个内部类叫做ThreadLocalMap,类似于HashMap

ThreadLocal 使用一个 ThreadLocalMap 来存储每个线程的变量副本,其中键为 ThreadLocal 实例,值为对应线程的变量副本。当一个线程创建一个 ThreadLocal 变量时,实际上是在当前线程的 ThreadLocalMap 中存储了一个键值对。当一个线程访问 ThreadLocal 变量时,实际上是在访问该线程自己的变量副本,而不是共享变量。这样可以保证线程之间的数据隔离,避免了线程安全问题。

4.ThreadLocal-内存泄露问题的原因?

每个线程都有⼀个ThreadLocalMap的内部属性,map的key是ThreaLocal,定义为弱引用,value是强引用类型。垃圾回收的时候会⾃动回收key,而value的回收取决于Thread对象的生命周期。一般会通过线程池的方式复用线程节省资源,这也就导致了线程对象的生命周期比较长,这样便一直存在一条强引用链的关系:Thread –> ThreadLocalMap–>Entry–>Value,随着任务的执行,value就有可能越来越多且无法释放,最终导致内存泄漏。

解决⽅法:每次使⽤完ThreadLocal就调⽤它的remove()⽅法,手动将对应的键值对删除,从⽽避免内存泄漏

Java对象中的四种引用类型:强引用、软引用、弱引用、虚引用

  • 强引用:最为普通的引用方式,表示一个对象处于有用且必须的状态,如果一个对象具有强引用,则GC并不会回收它。即便堆中内存不足了,宁可出现OOM,也不会对其进行回收
  • 弱引用:表示一个对象处于可能有用且非必须的状态。在GC线程扫描内存区域时,一旦发现弱引用,就会回收到弱引用相关联的对象。对于弱引用的回收,无关内存区域是否足够,一旦发现则会被回收

5.如何用ThreadLocal实现可重入锁?

ThreadLocal可以实现一个简单的可重入锁,以下是实现方法:

  1. 创建一个ThreadLocal变量,用于存储每个线程的锁计数器。
  2. 当线程请求锁时,检查ThreadLocal中的计数器值。如果计数器值为0,说明该线程尚未获得锁,将计数器值加1,并将锁状态设置为true。如果计数器值大于0,说明该线程已经获得了锁,再次将计数器值加1,表示锁的重入次数增加。
  3. 当线程释放锁时,将ThreadLocal中的计数器值减1。如果计数器值为0,说明该线程已经释放了所有的锁,将锁状态设置为false。否则,表示该线程仍然持有锁,只是减少了锁的重入次数。

4.线程池

1. 什么是线程池?为什么用它?

线程池:一个管理线程的池子。

为什么平时都是使用线程池创建线程,直接new一个线程不好吗?

1.手动创建线程的缺点

嗯,手动创建线程有两个缺点

  1. 不受控风险
  2. 频繁创建开销大

为什么不受控

系统资源有限,每个人针对不同业务都可以手动创建线程,并且创建线程没有统一标准,比如创建的线程有没有名字等。当系统运行起来,所有线程都在抢占资源,毫无规则,混乱场面可想而知,不好管控。

频繁手动创建线程为什么开销会大?跟new Object() 有什么差别?

虽然Java中万物皆对象,但是new Thread() 创建一个线程和 new Object()还是有区别的。

new Object()过程如下:

  1. JVM分配一块内存 M
  2. 在内存 M 上初始化该对象
  3. 将内存 M 的地址赋值给引用变量 obj

创建线程的过程如下:

  1. JVM为一个线程栈分配内存,该栈为每个线程方法调用保存一个栈帧
  2. 每一栈帧由一个局部变量数组、返回值、操作数堆栈和常量池组成
  3. 每个线程获得一个程序计数器,用于记录当前虚拟机正在执行的线程指令地址
  4. 系统创建一个与Java线程对应的本机线程
  5. 将与线程相关的描述符添加到JVM内部数据结构中
  6. 线程共享堆和方法区域

创建一个线程大概需要1M左右的空间(Java8,机器规格2c8G)。可见,频繁手动创建/销毁线程的代价是非常大的。

2.为什么使用线程池?

  • 降低资源消耗。通过重复利用已创建的线程降低线程创建和销毁造成的消耗。
  • 提高响应速度。当任务到达时,可以不需要等到线程创建就能立即执行。
  • 提高线程的可管理性。统一管理线程,避免系统创建大量同类线程而导致消耗完内存。

2.怎么创建线程池?

方式一:通过ThreadPoolExecutor手动创建(推荐)。

方式二:通过Executors自动创建(不推荐)

0.4种线程池

通过Executors创建线程池的4个方法:

  • **FixedThreadPool**:该方法返回一个固定线程数量的线程池。该线程池中的线程数量始终不变。当有一个新的任务提交时,线程池中若有空闲线程,则立即执行。若没有,则新的任务会被暂存在一个任务队列中,待有线程空闲时,便处理在任务队列中的任务。
  • SingleThreadExecutor 该方法返回一个只有一个线程的线程池。若多余一个任务被提交到该线程池,任务会被保存在一个任务队列中,待线程空闲,按先入先出的顺序执行队列中的任务。
  • CachedThreadPool 该方法返回一个可根据实际情况调整线程数量的线程池。初始大小为 0。当有新任务提交时,如果当前线程池中没有线程可用,它会创建一个新的线程来处理该任务。如果在一段时间内(默认为 60 秒)没有新任务提交,核心线程会超时并被销毁,从而缩小线程池的大小。
  • **ScheduledThreadPool**:该方法返回一个用来在给定的延迟后运行任务或者定期执行任务的线程池。

1.手写创建线程池的代码

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
public class ThreadPoolExecutorTest {
//它创建了一个线程池,最大线程数为5,核心线程数为2,空闲线程存活时间为2秒,任务队列容量为3。然后向线程池提交了5个任务,每个任务打印当前线程的名称和"ok"。最后关闭线程池。
//跑6条线程,那么6 >= 核心线程数+阻塞队列中的线程数,就会启用其他三条线程中的一个
//跑8条线程,那么8 >= 核心线程数+阻塞队列中的线程数,就会启用其他三条线程中的三个
//跑9条线程,那么9 >= 核心线程数+阻塞队列中的线程数,且9 > 最大线程数(5) + 阻塞队列中的线程数(8),则会触发拒绝策略,这里使用的拒绝策略是AbortPolicy,也就是拒绝处理,并抛出异常
public static void main(String[] args) {
ExecutorService threadPool = new ThreadPoolExecutor(
2,
5,
2,
TimeUnit.SECONDS,
new LinkedBlockingQueue<>(3),
Executors.defaultThreadFactory(),
new ThreadPoolExecutor.AbortPolicy()
);

try {
for (int i = 1; i <= 5 ; i++) {
threadPool.execute(()-> {
System.out.println(Thread.currentThread().getName() + "ok");
});
}
} catch (Exception e) {
e.printStackTrace();
} finally {
threadPool.shutdown();
}
}
}
/**
* pool-1-thread-1ok
* pool-1-thread-2ok
* pool-1-thread-2ok
* pool-1-thread-2ok
* pool-1-thread-1ok
*/

2.为什么不建议用Executors创建线程池

其实这个事情在阿里提供的最新开发手册《Java开发手册-嵩山版》中也提到了

主要原因是如果使用Executors创建线程池的话,它允许的请求队列默认长度是Integer.MAX_VALUE,这样的话,有可能导致堆积大量的请求,从而导致OOM(内存溢出)。

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

3 说一下线程池的核心参数

  • corePoolSize 核心线程数目
  • maximumPoolSize 最大线程数目 = (核心线程+救急线程的最大数目)
  • keepAliveTime 当线程数大于核心线程数时,多余的空闲线程存活的最长时间
  • unit 时间单位 - 救急线程的生存时间单位,如秒、毫秒等
  • blockingQueue 阻塞队列- 当没有空闲核心线程时,新来任务会加入到此队列排队,队列满会创建救急线程执行任务
  • threadFactory 线程工厂 - 每当线程池创建一个新的线程时,都是通过线程工厂方法来完成的。在 ThreadFactory 中只定义了一个方法 newThread,每当线程池需要创建新线程就会调用它。可以定制线程对象的创建,例如设置线程名字、是否是守护线程等
  • handler 拒绝策略 - 当所有线程都在繁忙,blockingQueue 也放满时,会触发拒绝策略。

4.线程池处理任务的流程

工作流程

1,任务在提交的时候,首先判断核心线程数是否已满,如果没有满这时对于一个新提交的任务,线程池会创建一个线程去处理任务。(当线程池里面存活的线程数小于等于核心线程数corePoolSize时,线程池里面的线程会一直存活着,就算空闲时间超过了keepAliveTime,线程也不会被销毁,而是一直阻塞在那里一直等待任务队列的任务来执行。)

2,如果核心线程数满了,则判断阻塞队列是否已满,如果没有满,当前任务存入阻塞队列,等待后续线程来执行提交地任务

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

4,如果当前的线程数达到了最大线程数目,并且任务队列也满了,如果还有新的任务过来,那就直接采用拒绝策略进行处理。默认的拒绝策略是抛出一个RejectedExecutionException异常。

5.线程池拒绝策略

1.AbortPolicy:直接抛出异常,默认策略;

2.CallerRunsPolicy:用调用者所在的线程来执行任务;

3.DiscardOldestPolicy:丢弃阻塞队列中靠最前的任务,并执行当前任务;

4.DiscardPolicy:直接丢弃任务;

6.线程池中有哪些常见的阻塞队列

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

线程池中常见的阻塞队列有几种,包括但不限于:

  1. ArrayBlockingQueue:基于数组实现的有界阻塞队列。它按照先进先出(FIFO)的原则对元素进行排序,当队列满时,尝试添加元素的线程将会被阻塞,直到队列中有空闲位置。
  2. LinkedBlockingQueue:通常容量设为Integer.MAX_VALUE,可以视为无界队列。适用于那些任务数量可能会非常多的场景,例如在FixedThreadPool和SingleThreadExecutor线程池中使用,这两种线程池的核心线程数和最大线程数是一致的,当任务多的时候,处理不过来的任务就会放到这个队列中等待处理。
  3. SynchronousQueue:不存储元素的阻塞队列。每个插入操作必须等待另一个线程进行相应的取出操作,反之亦然。它通常用在CachedThreadPool线程池中,这种线程池的特点是可以无限创建新线程,因此需要一个能够严格控制任务数量的队列来防止资源耗尽。
  4. PriorityBlockingQueue:支持优先级的无界阻塞队列。采用了与堆数据结构类似的方法,使得队列中的元素可以按照自然顺序或者自定义的Comparator排序。

ArrayBlockingQueue的LinkedBlockingQueue区别

LinkedBlockingQueue****ArrayBlockingQueue

默认无界,支持有界 强制有界
底层是链表 底层是数组
是懒惰的,创建节点的时候添加数据 提前初始化 Node 数组
入队会生成新 Node Node需要是提前创建好的
两把锁(头尾) 一把锁

左边是LinkedBlockingQueue加锁的方式,右边是ArrayBlockingQueue加锁的方式

  • LinkedBlockingQueue读和写各有一把锁,性能相对较好
  • ArrayBlockingQueue只有一把锁,读和写公用,性能相对于LinkedBlockingQueue差一些

7.怎么给线程池命名

利用 guava 的 ThreadFactoryBuilder

1
2
3
ThreadFactory threadFactory = new ThreadFactoryBuilder()
.setNameFormat(threadNamePrefix + "-%d")
.setDaemon(true).build();

8.如何确定核心线程数/线程池大小

如果我们设置的线程池数量太小的话,如果同一时间有大量任务/请求需要处理,可能会导致大量的请求/任务在任务队列中排队等待执行,甚至会出现任务队列满了之后任务/请求无法处理的情况,或者大量任务堆积在任务队列导致 OOM。这样很明显是有问题的,CPU 根本没有得到充分利用。

如果我们设置线程数量太大,大量线程可能会同时在争取 CPU 资源,这样会导致大量的上下文切换,从而增加线程的执行时间,影响了整体执行效率。

有一个简单并且适用面比较广的公式:

  • CPU 密集型任务(N+1): 这种任务消耗的主要是 CPU 资源,可以将线程数设置为 N(CPU 核心数)+1。比 CPU 核心数多出来的一个线程是为了防止线程偶发的缺页中断,或者其它原因导致的任务暂停而带来的影响。一旦任务暂停,CPU 就会处于空闲状态,而在这种情况下多出来的一个线程就可以充分利用 CPU 的空闲时间。
  • I/O 密集型任务(2N): 这种任务应用起来,系统会用大部分的时间来处理 I/O 交互,而线程在处理 I/O 的时间段内不会占用 CPU 来处理,这时就可以将 CPU 交出给其它线程使用。因此在 I/O 密集型任务的应用中,我们可以多配置一些线程,具体的计算方法是 2N。

9. execute和submit的区别

execute只能提交Runnable类型的任务,无返回值。submit既可以提交Runnable类型的任务,也可以提交Callable类型的任务,会有一个类型为Future的返回值,但当任务类型为Runnable时,返回值为null。

execute在执行任务时,如果遇到异常会直接抛出,而submit不会直接抛出,只有在使用Future的get方法获取返回值时,才会抛出异常

execute所属顶层接口是Executor,submit所属顶层接口是ExecutorService,实现类ThreadPoolExecutor重写了execute方法,抽象类AbstractExecutorService重写了submit方法。

10.怎么判断线程池的任务是不是执行完了?

有几种方法:

1、使用线程池的原生函数isTerminated();

executor提供一个原生函数isTerminated()来判断线程池中的任务是否全部完成。如果全部完成返回true,否则返回false。

2、使用重入锁,维持一个公共计数

所有的普通任务维持一个计数器,当任务完成时计数器加一(这里要加锁),当计数器的值等于任务数时,这时所有的任务已经执行完毕了。

3、使用CountDownLatch

它的原理跟第二种方法类似,给CountDownLatch一个计数值,任务执行完毕后,调用countDown()执行计数值减一。最后执行的任务在调用方法的开始调用await()方法,这样整个任务会阻塞,直到这个计数值为零,才会继续执行。

这种方式的缺点就是需要提前知道任务的数量。

4、submit向线程池提交任务,使用Future判断任务执行状态

使用submit向线程池提交任务与execute提交不同,submit会有Future类型的返回值。通过future.isDone()方法可以知道任务是否执行完成。

11.虚拟线程池和线程池有什么区别

虚拟线程池和线程池是处理并发任务的两种不同技术,它们在概念、性能开销以及适用场景上存在一些区别。具体分析如下:

  1. 概念:线程池是管理操作系统线程的一组队列和相关策略,用来执行并发任务。它减少了频繁创建和销毁线程的开销,可以控制并发线程的数量,避免系统过载。而虚拟线程(Virtual Thread)是Java 19中引入的概念,也称为轻量级线程或协程,在其他语言中可能被称为纤程或绿色线程。虚拟线程允许一个操作系统线程执行多个虚拟线程,从而能处理更多的用户请求。
  2. 性能开销:线程池中的每个线程都是一个操作系统级别的线程,它们的创建、切换和销毁都会产生一定的开销。特别是在IO密集型操作中,线程在等待数据时无法释放CPU资源。而虚拟线程的设计使得它们在IO操作期间可以让出CPU,执行其他任务,从而提高系统的吞吐量。
  3. 适用场景:线程池适合处理CPU密集型任务或者需要长时间运行的任务。而虚拟线程更适合处理大量的IO密集型任务,比如Web服务器处理HTTP请求等,因为在这些场景下,传统线程会有大量的时间处于等待状态。

综上所述,虚拟线程池和线程池的主要区别在于它们对操作系统资源的利用方式和管理任务的机制。虚拟线程提供了一种更加轻量级的并发处理方法,尤其适合于IO密集型操作,而线程池则适用于更广泛的场景,特别是那些需要长期运行或计算密集型的任务。

5.Future

1.什么是Future?

Future类的主要作用是提供一种机制,允许主线程异步执行任务,并在需要时获取任务的结果

简单理解就是:我有一个任务,提交给了 Future 来处理。任务执行期间我自己可以去做任何想做的事情。并且,在这期间我还可以取消任务以及获取任务的执行状态。一段时间之后,我就可以 Future 那里直接取出任务执行结果。

Future接口主要包括5个方法:

  1. get()方法可以当任务结束后返回一个结果,如果调用时,工作还没有结束,则会阻塞线程,直到任务执行完毕
  2. get(long timeout,TimeUnit unit)做多等待timeout的时间就会返回结果
  3. cancel(boolean mayInterruptIfRunning)方法可以用来停止一个任务,如果任务可以停止(通过mayInterruptIfRunning来进行判断),则可以返回true,如果任务已经完成或者已经停止,或者这个任务无法停止,则会返回false。
  4. isDone()方法判断当前方法是否完成
  5. isCancel()方法判断当前方法是否取消

6.AQS

1.AQS是什么?

AQS,全称AbstractQueuedSynchronizer,是Java并发包中一个用于实现锁和同步器的抽象类。,我们经常使用的ReentrantLock、CountDownLatch,都是基于AQS抽象同步式队列实现的。

AQS是个双向链表。

AQS与Synchronized的区别

synchronized****AQS

关键字,c++ 语言实现 java 语言实现
悲观锁,自动释放锁 悲观锁,手动开启和关闭
锁竞争激烈都是重量级锁,性能差 锁竞争激烈的情况下,提供了多种解决方案

AQS常见的实现类

  • ReentrantLock 阻塞式锁
  • Semaphore 信号量
  • CountDownLatch 倒计时锁

2.AQS原理是什么?

AQS,全称AbstractQueuedSynchronizer,是Java并发包中一个用于实现锁和同步器的抽象类。AQS的原理是通过维护一个状态变量和一个FIFO队列,结合CAS操作,来实现线程之间的同步和互斥控制。。以下是AQS的几个关键组成部分:

  1. 状态管理(State):AQS内部维护一个共享状态(state),这个状态可以表示为整数值,用于表示资源的获取情况或等待线程的数量。通过getState()方法可以获取当前同步状态的值。如果 state=0,说明没有任何线程占有共享资源的锁,可以获得锁并将 state加1。如果 state不为0,则说明有线程目前正在使用共享变量,其他线程必须加入同步队列进行等待。
  2. FIFO队列:AQS使用一个FIFO(先进先出)双向队列来管理等待的线程。当一个线程尝试获取资源但失败时,它会被加入到等待队列中。当资源被释放时,队列中的线程会被唤醒并尝试再次获取资源。
  3. CAS操作:AQS在底层使用了无锁编程技术中的CAS(Compare-And-Swap)操作来实现对状态的原子性更新。
  4. 独占锁和共享锁:AQS支持独占模式和共享模式两种类型的锁。独占模式下,同一时间只有一个线程能够访问资源;而共享模式下,多个线程可以同时访问资源。
  5. 公平与非公平锁:AQS可以提供公平锁和非公平锁的实现。公平锁是指等待时间最长的线程会优先获得资源,而非公平锁则不保证这一点。
    • 新的线程与队列中的线程共同来抢资源,是非公平锁
    • 新的线程到队列中等待,只让队列中的head线程获取锁,是公平锁

3.为什么AQS是双向链表而不是单向的?

双向链表有两个指针,一个指针指向前置节点,一个指针指向后继节点。所以,双向链表可以支持常量 O(1) 时间复杂度的情况下找到前驱节点。因此,双向链表在插入和删除操作的时候,要比单向链表简单、高效。

从双向链表的特性来看,AQS 使用双向链表有2个方面的原因:

  1. 没有竞争到锁的线程加入到阻塞队列,并且阻塞等待的前提是,当前线程所在节点的前置节点是正常状态,这样设计是为了避免链表中存在异常线程导致无法唤醒后续线程的问题。所以,线程阻塞之前需要判断前置节点的状态,如果没有指针指向前置节点,就需要从 Head 节点开始遍历,性能非常低。
  2. 在 Lock 接口里面有一个lockInterruptibly()方法,这个方法表示处于锁阻塞的线程允许被中断。也就是说,没有竞争到锁的线程加入到同步队列等待以后,是允许外部线程通过interrupt()方法触发唤醒并中断的。这个时候,被中断的线程的状态会修改成 CANCELLED。而被标记为 CANCELLED 状态的线程,是不需要去竞争锁的,但是它仍然存在于双向链表里面。这就意味着在后续的锁竞争中,需要把这个节点从链表里面移除,否则会导致锁阻塞的线程无法被正常唤醒。在这种情况下,如果是单向链表,就需要从 Head 节点开始往下逐个遍历,找到并移除异常状态的节点。同样效率也比较低,还会导致锁唤醒的操作和遍历操作之间的竞争。

4.semaphore信号量

1.Semaphore信号量 有什么用?

synchronizedReentrantLock 都是一次只允许一个线程访问某个资源,而Semaphore(信号量)可以用来控制同时访问特定资源的线程数量。

Semaphore 有两种模式:。

  • 公平模式: 调用 acquire() 方法的顺序就是获取许可证的顺序,遵循 FIFO;
  • 非公平模式: 抢占式的。

2.semaphore的原理

Semaphore 是共享锁的一种实现,它默认构造 AQS 的 state 值为 permits,你可以将 permits 的值理解为许可证的数量,只有拿到许可证的线程才能执行。

常用方法:

1
2
3
4
acquire()  
获取一个令牌,在获取到令牌、或者被其他线程调用中断之前线程一直处于阻塞状态。
release()
释放一个令牌,唤醒一个获取令牌不成功的阻塞线程。

调用semaphore.acquire() ,线程尝试获取许可证,如果 state >= 0 的话,则表示可以获取成功。如果获取成功的话,使用 CAS 操作去修改 state 的值 state=state-1。如果 state<0 的话,则表示许可证数量不足。此时会创建一个 Node 节点加入阻塞队列,挂起当前线程。

调用semaphore.release(); ,线程尝试释放许可证,并使用 CAS 操作去修改 state 的值 state=state+1。释放许可证成功之后,同时会唤醒同步队列中的一个线程。被唤醒的线程会重新尝试去修改 state 的值 state=state-1 ,如果 state>=0 则获取令牌成功,否则重新进入阻塞队列,挂起线程。

3.代码示例

以下代码模拟了对某个服务的并发请求,每次只能有 3 个客户端同时访问,请求总数为 10。

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
public class SemaphoreExample {

public static void main(String[] args) {
final int clientCount = 3;
final int totalRequestCount = 10;
//意味着最多允许clientCount个线程同时执行
Semaphore semaphore = new Semaphore(clientCount);
ExecutorService executorService = Executors.newCachedThreadPool();
for (int i = 0; i < totalRequestCount; i++) {
executorService.execute(()->{
try {
//调用semaphore.acquire()方法获取信号量。如果当前信号量的可用资源数量大于0,则会直接获取资源并继续执行;否则,线程将被阻塞,直到有可用资源为止。
semaphore.acquire();
//在获取资源后,打印出当前信号量的可用资源数量,即semaphore.availablePermits()。
System.out.print(semaphore.availablePermits() + " ");
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
//release()方法释放了一个信号量
semaphore.release();
}
});
}
//调用executorService.shutdown()方法关闭线程池
executorService.shutdown();
}

5.CountDownLatch倒计时锁

1.CountDownLatch有什么用

CountDownLatch倒计时锁用于某个线程等待其他线程执行完任务再执行,与thread.join()功能类似。常见的应用场景是开启多个线程同时执行某个任务,等到所有任务执行完再执行特定操作,如汇总统计结果

  • 其中构造参数用来初始化等待计数值
  • await() 用来等待计数归零,调用await()方法的线程会被挂起,它会等待直到count值为0才继续执行;等待timeout时间后count值还没变为0的话就会继续执行
  • countDown() 用来让计数减一

2.CountDownLatch 的原理是什么?

CountDownLatch 是共享锁的一种实现,它默认构造 AQS 的 state 值为 count。当线程使用 countDown() 方法时,其实使用了tryReleaseShared方法以 CAS 的操作来减少 state,直至 state 为 0 。当调用 await() 方法的时候,如果 state 不为 0,那就证明任务还没有执行完毕,await() 方法就会一直阻塞,也就是说 await() 方法之后的语句不会被执行。直到count 个线程调用了countDown()使 state 值被减为 0,或者调用await()的线程被中断,该线程才会从阻塞中被唤醒,await() 方法之后的语句得到执行

6. CyclicBarrier(同步屏障)

1.CyclicBarrier有什么用

CyclicBarrier(同步屏障),用于一组线程互相等待到某个状态,然后这组线程再同时执行。

通过CyclicBarrier实现了一种线程同步机制,即在所有线程都到达某个点之前,它们会一直等待;当所有线程都到达该点后,才会继续执行后续代码。

2.CyclicBarrier原理

CyclicBarrier 内部通过一个 count 变量作为计数器,count 的初始值为 parties 属性的初始化值,每当一个线程到了栅栏这里了,那么就将计数器减 1。如果 count 值为 0 了,表示这是这一代最后一个线程到达栅栏,就尝试执行我们构造方法中输入的任务。

3.代码实例

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

public static void main(String[] args) {
final int totalThread = 10;//表示线程总数
CyclicBarrier cyclicBarrier = new CyclicBarrier(totalThread);
ExecutorService executorService = Executors.newCachedThreadPool();
for (int i = 0; i < totalThread; i++) {
executorService.execute(() -> {
System.out.print("before..");
try {
//调用cyclicBarrier.await()进行等待,直到所有线程都到达屏障点。
cyclicBarrier.await();
} catch (InterruptedException | BrokenBarrierException e) {
e.printStackTrace();
}
System.out.print("after..");
});
}
//调用executorService.shutdown()关闭线程池。
executorService.shutdown();
}
}
//输出结果
before..before..before..before..before..before..before..before..before..before..after..after..after..after..after..after..after..after..after..after..

7.CyclicBarrier和CountDownLatch区别

CyclicBarrier 和 CountDownLatch 都能够实现线程之间的等待。

CountDownLatch用于某个线程等待其他线程执行完任务再执行。CyclicBarrier用于一组线程互相等待到某个状态,然后这组线程再同时执行。 CountDownLatch的计数器只能使用一次,而CyclicBarrier的计数器可以使用reset()方法重置,可用于处理更为复杂的业务场景。

7. JMM(Java 内存模型)

1.什么是JMM?

JMM(Java Memory Model)Java内存模型,是java虚拟机规范中所定义的一种内存模型。

Java内存模型(Java Memory Model)描述了在java中多线程环境下各个线程对共享变量的访问规则,以及在JVM中将变量存储到内存和从内存中读取变量这样的底层细节。

2.主内存和本地内存

  1. 所有的共享变量都存储于主内存(计算机的RAM)这里所说的变量指的是实例变量和类变量。不包含局部变量,因为局部变量是线程私有的,因此不存在竞争问题。
  2. 每一个线程还存在自己的工作内存,线程的工作内存,保留了被线程使用的变量的工作副本。
  3. 线程对变量的所有的操作(读,写)都必须在工作内存中完成,而不能直接读写主内存中的变量,不同线程之间也不能直接访问对方工作内存中的变量,线程间变量的值的传递需要通过主内存完成。

3.Java 内存区域和 JMM 有何区别?

这是一个比较常见的问题,很多初学者非常容易搞混。 Java 内存区域和内存模型是完全不一样的两个东西

  • JVM 内存结构和 Java 虚拟机的运行时区域相关,定义了 JVM 在运行时如何分区存储程序数据,就比如说堆主要用于存放对象实例。
  • Java 内存模型和 Java 的并发编程相关,抽象了线程和主内存之间的关系就比如说线程之间的共享变量必须存储在主内存中,规定了从 Java 源代码到 CPU 可执行指令的这个转

8.虚拟线程

1.什么是虚拟线程?

虚拟线程(Virtual Thread)是 JDK 而不是 OS 实现的轻量级线程(Lightweight Process,LWP),由 JVM 调度。许多虚拟线程共享同一个操作系统线程,虚拟线程的数量可以远大于操作系统线程的数量。

2.虚拟线程有什么优点和缺点?

优点

  • 非常轻量级:可以在单个线程中创建成百上千个虚拟线程而不会导致过多的线程创建和上下文切换。
  • 简化异步编程: 虚拟线程可以简化异步编程,使代码更易于理解和维护。它可以将异步代码编写得更像同步代码,避免了回调地狱(Callback Hell)。
  • 减少资源开销: 相比于操作系统线程,虚拟线程的资源开销更小。本质上是提高了线程的执行效率,从而减少线程资源的创建和上下文切换。

缺点

  • 不适用于计算密集型任务: 虚拟线程适用于 I/O 密集型任务,但不适用于计算密集型任务,因为密集型计算始终需要 CPU 资源作为支持。
  • 依赖于语言或库的支持: 协程需要编程语言或库提供支持。不是所有编程语言都原生支持协程。比如 Java 实现的虚拟线程。

3.虚拟线程的创建方法

  1. 使用 Thread.startVirtualThread() 创建
  2. 使用 Thread.ofVirtual() 创建
  3. 使用 ThreadFactory 创建