系列文章目录
- 2024年java面试(一)–spring篇
- 2024年java面试(二)–spring篇
- 2024年java面试(三)–spring篇
- 2024年java面试(四)–spring篇
- 2024年java面试–集合篇
- 2024年java面试–redis(1)
- 2024年java面试–redis(2)
文章目录
- 系列文章目录
- 内存模型
- 1、volatile底层实现
- 2、AQS思想
- 3、happens-before
- 启动线程方法 start()和 run()有什么区别?
- 多线程同步有哪几种方法?
- 死锁
- 多线程之间如何进行通信
- 线程怎样拿到返回结果
- 多线程执行问题
- Q1:有 A、B、C 三个线程,如何保证三个线程同时执行?
- Q2:有 A、B、C 三个线程,在并发情况下,如何保证三个线程依次执行?
- Q3:有 A、B、C 三个线程,如何保证三个线程有序交错执行?
- 线程池启动线程 submit()和 execute()方法有什么不同
- 活锁、饥饿、无锁、死锁
- 什么是原子性、可见性、有序性
- 什么是守护线程?有什么用?
- 如何创建线程安全的单例模式
- 如果你提交任务时,线程池队列已满,这时会发生什么
- Synchrpnized和lock的区别
- 常见线程安全的并发容器有哪些
内存模型
Java 内存模型(Java Memory Model,JMM)就是一种符合内存模型规范的,屏蔽了各种硬件和操作系统的访问差异的,保证了 Java 程序在各种平台下对内存的访问都能保证效果一致的机制及规范。
JMM 是一种规范,是解决由于多线程通过共享内存进行通信时,存在的本地内存数据不一致、编译器会对代码指令重排序、处理器会对代码乱序执行等带来的问题。目的是保证并发编程场景中的原子性、可见性和有序性。
原子性
在 Java 中,为了保证原子性,提供了两个高级的字节码指令 Monitorenter 和 Monitorexit。这两个字节码,在 Java 中对应的关键字就是 Synchronized。因此,在 Java 中可以使用 Synchronized 来保证方法和代码块内的操作是原子性的。
可见性
Java 中的 Volatile 关键字修饰的变量在被修改后可以立即同步到主内存。被其修饰的变量在每次使用之前都从主内存刷新。因此,可以使用 Volatile 来保证多线程操作时变量的可见性。除了 Volatile,Java 中的 Synchronized 和 Final 两个关键字也可以实现可见性。只不过实现方式不同
有序性
在 Java 中,可以使用 Synchronized 和 Volatile 来保证多线程之间操作的有序性。区别:Volatile 禁止指令重排。Synchronized 保证同一时刻只允许一条线程操作。
1、volatile底层实现
作用:
1.可见性: 当一个线程修改了volatile修饰的变量的值,其他线程可以立即看到这个修改,保证了共享变量的可见性。
2.禁止指令重排序: 编译器和处理器在编译和执行代码时,可能会对指令进行重排序,但是volatile关键字可以禁止这种重排序,保证了程序的正确性。
3.保证原子性: volatile关键字可以保证一些简单的操作的原子性,例如++操作,但是对于复合操作,volatile关键字无法保证原子性。
底层实现:
“观察加入volatile关键字和没有加入volatile关键字时所生成的汇编代码发现,加入volatile关键字时,会多出一个lock前缀指令”
lock前缀指令实际上相当于一个内存屏障(也成内存栅栏),内存屏障会提供3个功能:
1)它确保指令重排序时不会把其后面的指令排到内存屏障之前的位置,也不会把前面的指令排到内存屏障的后面;
2)它会强制将对缓存的修改操作立即写入主存;
3)如果是写操作,它会导致其他CPU中对应的缓存行无效。
单例模式中volatile的作用:(DCL(Double Check Lock)单例为什么要加Volatile?)
防止代码读取到instance不为null时,instance引用的对象有可能还没有完成初始化。
volatile防止指令重排,在DCL中,防止高并发情况下,指令重排造成的线程安全问题。
class Singleton{
private volatile static Singleton instance = null; //禁止指令重排
private Singleton() {
}
public static Singleton getInstance() {
if(instance==null) { //减少加锁的损耗
synchronized (Singleton.class) {
if(instance==null) //确认是否初始化完成
instance = new Singleton();
}
}
return instance;
}
}
volatile关键字和synchronized关键字的区别
- volatile关键字保证了共享变量的可见性和禁止指令重排序,但是无法保证原子性,而synchronized关键字可以保证原子性、有序性和可见性。
- volatile关键字适用于一些简单的操作,例如++操作,而synchronized关键字适用于复合操作。
- volatile关键字不会造成线程阻塞,而synchronized关键字可能会造成线程阻塞。
volatile能不能保证线程安全?
volatile不能保证线程安全,只能保证线程可见性,不能保证原子性
flag常量未使用volatile关键字的时候,程序一直不停止,因为无法感知flag的变化
使用volatile关键字后,因为volatile保证了可见性,感知到了flag的变化,程序停止并打印
2、AQS思想
AQS的全称为(AbstractQueuedSynchronizer)抽象的队列式的同步器,是⼀个⽤来构建锁和同步器的框架,使⽤AQS能简单且⾼效地构造出应⽤⼴泛的⼤量的同步器,如:基于AQS实现的lock, CountDownLatch、CyclicBarrier、Semaphore需解决的问题:
- 状态的原子性管理
- 线程的阻塞与解除阻塞
- 队列的管理
AQS核⼼思想是,如果被请求的共享资源空闲,则将当前请求资源的线程设置为有效的⼯作线程,并且将共享资源设置为锁定状态。如果被请求的共享资源被占⽤,那么就需要⼀套线程阻塞等待以及被唤醒时锁分配的机制,这个机制AQS是⽤CLH(虚拟的双向队列) 队列锁实现的,即将暂时获取不到锁的线程加⼊到队列中。
lock:
是一种可重入锁,除了能完成 synchronized 所能完成的所有工作外,还提供了诸如可响应中断锁、可轮询锁请求、定时锁等避免多线程死锁的方法。默认为非公平锁,但可以初始化为公平锁; 通过方法 lock()与 unlock()来进行加锁与解锁操作;
常见AQS锁:
CountDownLatch(倒计数器):
通过计数法(倒计时器),让一些线程堵塞直到另一个线程完成一系列操作后才被唤醒;该⼯具通常⽤来控制线程等待,它可以让某⼀个线程等待直到倒计时结束,再开始执⾏。具体可以使用countDownLatch.await()来等待结果。多用于多线程信息汇总。
CompletableFuture(独占锁):
通过设置参数,可以完成CountDownLatch同样的多平台响应问题,但是可以针对其中部分返回结果做更加灵活的展示。
CyclicBarrier(循环栅栏):
字面意思是可循环(Cyclic)使用的屏障(Barrier)。他要做的事情是,让一组线程到达一个屏障(也可以叫同步点)时被阻塞,直到最后一个线程到达屏障时,屏障才会开门,所有被屏障拦截的线程才会继续干活,线程进入屏障通过CyclicBarrier的await()方法。可以用于批量发送消息队列信息、异步限流。
Semaphore(信号量):
信号量主要用于两个目的,一个是用于多个共享资源的互斥作用,另一个用于并发线程数的控制。SpringHystrix限流的思想
为什么AQS使用的双向链表
因为有一些线程可能发生中断 ,而发生中断时候就需要在同步阻塞队列中删除掉,这个时候用双向链表方便删除掉中间的节点
3、happens-before
用来描述和可见性相关问题:如果第一个操作 happens-before 第二个操作,那么我们就说第一个操作对于第二个操作是可见的
常见的happens-before:volatile 、锁、线程生命周期。
启动线程方法 start()和 run()有什么区别?
只有调用了 start()方法,才会表现出多线程的特性,不同线程的 run()方法里面的代码交替执行。如果只是调用 run()方法,那么代码还是同步执行的,必须等待一个线程的 run()方法里面的代码全部执行完毕之后,另外一个线程才可以执行其 run()方法里面的代码。
多线程同步有哪几种方法?
Synchronized 关键字,Lock 锁实现,分布式锁等。
死锁
死锁就是两个线程相互等待对方释放对象锁
多线程之间如何进行通信
wait/notify
线程怎样拿到返回结果
实现Callable 接口
多线程执行问题
Q1:有 A、B、C 三个线程,如何保证三个线程同时执行?
保证线程同时执行可以用于并发测试。可以使用倒计时锁CountDownLatch实现让三个线程同时执行。
将其计数器初始为1,多个线程在开始执行任务前首先countdownlatch.await(),当主线程调用countDown()方法时,计数器变为0,多个线程同时被唤醒执行任务。
代码如下所示:
ExecutorService executorService = Executors.newCachedThreadPool();
CountDownLatch countDownLatch = new CountDownLatch(1);
executorService.submit(()->{
try {
countDownLatch.await();
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("线程A执行,执行时间:" + System.currentTimeMillis());
});
executorService.submit(()->{
try {
countDownLatch.await();
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("线程B执行,执行时间:" + System.currentTimeMillis());
});
executorService.submit(()->{
try {
countDownLatch.await();
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("线程C执行,执行时间:" + System.currentTimeMillis());
});
countDownLatch.countDown();
打印内容如下,通过时间可以证明三个线程是同时执行的。
线程A执行,执行时间:1617811258309
线程C执行,执行时间:1617811258309
线程B执行,执行时间:1617811258309
让三个线程同时执行,也可以使用栅栏 CyvlivBarrier 来实现,当三个线程都到达栅栏处,才开始执行。
Q2:有 A、B、C 三个线程,在并发情况下,如何保证三个线程依次执行?
- 用 join 方法
使用 join() 方法可以保证线程的顺序执行。在 Java 中,join() 方法是用来等待一个线程执行完成的方法,当调用某个线程的 join() 方法时,当前线程会被阻塞,直到该线程执行完成后才会继续执行。
具体来说,我们可以在 T1 线程结束时调用 T2 的 join() 方法,这样 T2 就会等待 T1 执行完成后再开始执行;同理,在 T2 结束时调用 T3 的 join() 方法,以确保 T3 在 T2 执行完成后才开始执行。这样就可以保证 T1、T2、T3 按照顺序依次执行。
- 使用CountDownLatch(闭锁)
使用 CountDownLatch(闭锁)方法可以保证线程的顺序执行。CountDownLatch 是一个同步工具类,它可以让某个线程等待多个线程完成各自的工作之后再继续执行。
@Test
public void testUseCountDownLatch() {
ExecutorService executorService = Executors.newCachedThreadPool();
CountDownLatch aLatch = new CountDownLatch(1);
CountDownLatch bLatch = new CountDownLatch(1);
CountDownLatch cLatch = new CountDownLatch(1);
executorService.submit(() -> {
try {
aLatch.await();
} catch (InterruptedException e) {
e.printStackTrace();
}
for (int i = 0; i < 10; i++) {
System.out.println("A - " + i);
}
bLatch.countDown();
});
executorService.submit(() -> {
try {
bLatch.await();
} catch (InterruptedException e) {
e.printStackTrace();
}
for (int i = 0; i < 10; i++) {
System.out.println("B - " + i);
}
cLatch.countDown();
});
executorService.submit(() -> {
try {
cLatch.await();
} catch (InterruptedException e) {
e.printStackTrace();
}
for (int i = 0; i < 10; i++) {
System.out.println("C - " + i);
}
});
aLatch.countDown();
}
- volatile 使用一个变量进行判断执行哪个线程。没有轮到的线程在不停循环,没有停止线程
private volatile int count = 0;
/**
* 使用一个变量进行判断执行哪个线程。没有轮到的线程在不停循环,没有停止线程
*/
public void useVolatile() {
ExecutorService executorService = Executors.newCachedThreadPool();
executorService.submit(() -> {
while (true) {
if (count == 0) {
for (int i = 0; i < 5; i++) {
System.out.println("A - " + i);
}
count = 1;
break;
}
}
});
executorService.submit(() -> {
while (true) {
if (count == 1) {
for (int i = 0; i < 5; i++) {
System.out.println("B - " + i);
}
count = 2;
break;
}
}
});
executorService.submit(() -> {
while (true) {
if (count == 2) {
for (int i = 0; i < 5; i++) {
System.out.println("C - " + i);
}
count = 3;
break;
}
}
});
}
- 使用单个线程池
使用单个线程池可以保证t1、t2、t3顺序执行,因为单个线程池只有一个工作线程,每次只会执行一个任务。我们可以将t1、t2、t3三个任务按照顺序提交给单个线程池,这样就可以确保它们按照顺序依次执行。
Q3:有 A、B、C 三个线程,如何保证三个线程有序交错执行?
实现三个线程交错打印,可以使用ReentrantLock以及3个Condition来实现
线程池启动线程 submit()和 execute()方法有什么不同
execute没有返回值,如果不需要知道线程的结果就使用execute方法,性能会好很多。
submit返回一个Future对象,如果想知道线程结果就使用submit提交,而且它能在主线程中通过Future的get方法捕获线程中的异常。
活锁、饥饿、无锁、死锁
死锁
死锁是多线程中最差的一种情况,多个线程相互占用对方的资源的锁,而又相互等对方释放锁,此时若无外力干预,这些线程则一直处理阻塞的假死状态,形成死锁。举个例子,A同学抢了B同学的钢笔,B同学抢了A同学的书,两个人都相互占用对方的东西,都在让对方还给自己自己再还,这样一直争执下去等待对方还而又得不到解决,老师知道此事后就让他们相互还给对方,这样在外力的干预下他们才解决,当然这只是个例子没有老师他们也能很好解决,计算机不像人如果发现这种情况没有外力干预还是会一直阻塞下去的。
活锁
活锁这个概念大家应该很少有人听说或理解它的概念,而在多线程中这确实存在。活锁恰恰与死锁相反,死锁是大家都拿不到资源都占用着对方的资源,而活锁是拿到资源却又相互释放不执行。当多线程中出现了相互谦让,都主动将资源释放给别的线程使用,这样这个资源在多个线程之间跳动而又得不到执行,这就是活锁。
饥饿
我们知道多线程执行中有线程优先级这个东西,优先级高的线程能够插队并优先执行,这样如果优先级高的线程一直抢占优先级低线程的资源,导致低优先级线程无法得到执行,这就是饥饿。当然还有一种饥饿的情况,一个线程一直占着一个资源不放而导致其他线程得不到执行,与死锁不同的是饥饿在以后一段时间内还是能够得到执行的,如那个占用资源的线程结束了并释放了资源。
无锁
无锁,即没有对资源进行锁定,即所有的线程都能访问并修改同一个资源,但同时只有一个线程能修改成功。无锁典型的特点就是一个修改操作在一个循环内进行,线程会不断的尝试修改共享资源,如果没有冲突就修改成功并退出否则就会继续下一次循环尝试。所以,如果多个线程修改同一个值必定会有一个线程能修改成功,而其他修改失败的线程会不断重试直到修改成功。之前的文章我介绍过JDK的CAS原理及应用即是无锁的实现。
什么是原子性、可见性、有序性
原子性
原子性是指一个线程的操作是不能被其他线程打断,同一时间只有一个线程对一个变量进行操作。在多线程情况下,每个线程的执行结果不受其他线程的干扰,比如说多个线程同时对同一个共享成员变量n++100次,如果n初始值为0,n最后的值应该是100,所以说它们是互不干扰的,这就是传说的中的原子性。但n++并不是原子性的操作,要使用AtomicInteger保证原子性。
可见性
可见性是指某个线程修改了某一个共享变量的值,而其他线程是否可以看见该共享变量修改后的值。在单线程中肯定不会有这种问题,单线程读到的肯定都是最新的值,而在多线程编程中就不一定了。每个线程都有自己的工作内存,线程先把共享变量的值从主内存读到工作内存,形成一个副本,当计算完后再把副本的值刷回主内存,从读取到最后刷回主内存这是一个过程,当还没刷回主内存的时候这时候对其他线程是不可见的,所以其他线程从主内存读到的值是修改之前的旧值。像CPU的缓存优化、硬件优化、指令重排及对JVM编译器的优化,都会出现可见性的问题。
有序性
我们都知道程序是按代码顺序执行的,对于单线程来说确实是如此,但在多线程情况下就不是如此了。为了优化程序执行和提高CPU的处理性能,JVM和操作系统都会对指令进行重排,也就说前面的代码并不一定都会在后面的代码前面执行,即后面的代码可能会插到前面的代码之前执行,只要不影响当前线程的执行结果。所以,指令重排只会保证当前线程执行结果一致,但指令重排后势必会影响多线程的执行结果。虽然重排序优化了性能,但也是会遵守一些规则的,并不能随便乱排序,只是重排序会影响多线程执行的结果。
什么是守护线程?有什么用?
什么是守护线程?与守护线程相对应的就是用户线程,守护线程就是守护用户线程,当用户线程全部执行完结束之后,守护线程才会跟着结束。也就是守护线程必 须伴随着用户线程,如果一个应用内只存在一个守护线程,没有用户线程,守护线程自然会退出。
举例,GC垃圾回收线程:就是一个经典的守护线程,当我们的程序中不再有任何运行的Thread,程序就不会再产生垃圾,垃圾回收器也就无事可做,所以当垃圾回收线程是VM上仅剩的线程时,垃圾回收线程会自动离开。它始终在低级别的状态中运行,用于实时监控和管理系统中的可回收资源。
如何创建线程安全的单例模式
- 饿汉式:线程安全速度快,饿汉就是类一旦加载,就把单例初始化完成,保证getInstance的时候,单例是已经存在的了。
- 懒汉式:双重检测锁,第一次减少锁的开销、第二次防止重复、volatile防止重排序导致实例化未完成,而懒汉比较懒,只有当调用getInstance的时候,才回去初始化这个单例。
如果你提交任务时,线程池队列已满,这时会发生什么
-
如果使用的无界队列,那么可以继续提交任务时没关系的
-
如果使用的有界队列,提交任务时,如果队列满了,如果核心线程数没有达到上限,那么则增加线程,如果线程数已经达到了最大值,则使用拒绝策略进行拒绝
Synchrpnized和lock的区别
-
synchronized是关键字,lock是一个类
-
synchronized在发生异常时会自动释放锁,lock需要手动释放锁
-
synchronized是可重入锁、非公平锁、不可中断锁,lock的ReentrantLock是可重入锁,可中断锁,可以是公平锁也可以是非公平锁
-
synchronized是JVM层次通过监视器实现的,Lock是通过AQS实现的
常见线程安全的并发容器有哪些
- CopyOnWriteArrayList、CopyOnWriteArraySet、ConcurrentHashMap
- CopyOnWriteArrayList、CopyOnWriteArraySet采用写时复制实现线程安全
- ConcurrentHashMap采用分段锁的方式实现线程安全