java 的这些多线程面试专题,你都知道吗?
多线程技巧目录
什么是进程、线程,他们有什么区别?进程
狭义定义:进程是正在运行的程序的实例(an instance of a computer program that is being executed)。 广义定义:进程是一个具有一定独立功能的程序关于某个数据集合的一次运行活动。它是操作系统动态执行的基本单元,在传统的操作系统中,进程既是基本的分配单元,也是基本的执行单元。
进程的概念主要有两点:第一,进程是一个实体。每一个进程都有它自己的地址空间,一般情况下,包括文本区域(text region)、数据区域(data region)和堆栈(stack region)。文本区域存储处理器执行的代码;数据区域存储变量和进程执行期间使用的动态分配的内存;堆栈区域存储着活动过程调用的指令和本地变量。第二,进程是一个“执行中的程序”。程序是一个没有生命的实体,只有处理器赋予程序生命时(操作系统执行之),它才能成为一个活动的实体,我们称其为进程。
进程是操作系统中最基本、重要的概念。是多道程序系统出现后,为了刻画系统内部出现的动态情况,描述系统内部各道程序的活动规律引进的一个概念,所有多道程序设计操作系统都建立在进程的基础上(百度百科)。
进程是内存中一块独立的空间。
线程
(英语:thread)是操作系统能够进行运算调度的最小单位。大部分情况下,它被包含在进程之中,是进程中的实际运作单位。一条线程指的是进程中一个单一顺序的控制流,一个进程中可以并发多个线程,每条线程并行执行不同的任务。在 Unix System V 及 SunOS 中也被称为轻量进程(lightweight processes),但轻量进程更多指内核线程(kernel thread),而把用户线程(user thread)称为线程。
线程是独立调度和分派的基本单位。线程可以为操作系统内核调度的内核线程,如 Win32 线程;由用户进程自行调度的用户线程,如 Linux 平台的 POSIX Thread;或者由内核与用户进程,如 Windows 7 的线程,进行混合调度。
同一进程中的多条线程将共享该进程中的全部系统资源,如虚拟地址空间,文件描述符和信号处理等等。但同一进程中的多个线程有各自的调用栈(call stack),自己的寄存器环境(register context),自己的线程本地存储(thread-local storage)。
一个进程可以有很多线程来处理,每条线程并行执行不同的任务。如果进程要完成的任务很多,这样需很多线程,也要调用很多核心,在多核或多 CPU,或支持 Hyper-threading 的 CPU 上使用多线程程序设计的好处是显而易见的,即提高了程序的执行吞吐率。以人工作的样子想像,核心相当于人,人越多则能同时处理的事情越多,而线程相当于手,手越多则工作效率越高。在单 CPU 单核的计算机上,使用多线程技术,也可以把进程中负责 I/O 处理、人机交互而常被阻塞的部分与密集计算的部分分开来执行,编写专门的 workhorse 线程执行密集计算,虽然多任务比不上多核,但因为具备多线程的能力,从而提高了程序的执行效率(维基百科)。
线程是进程的最小运行单位,依赖于进程。
同步与异步有什么区别?同步:(英语:Synchronization),指对在一个系统中所发生的事件(event)之间进行协调,在时间上出现一致性与统一化的现象。在系统中进行同步,也被称为及时(in time)、同步化的(synchronous、in sync)。
线程同步:当有一个线程正在对内存某个属性进行操作时,其他线程需处于阻塞状态,直到当前线程执行结束,其他线程才能竞争内存中的属性。这就是线程同步。
异步:异步双方不需要共同的时钟,也就是接收方不知道发送方什么时候发送,所以在发送的信息中就要有提示接收方开始接收的信息,如开始位,同时在结束时有停止位。
什么是上下文切换上下文切换指的是内核(操作系统的核心)在 CPU 上对进程或者线程进行切换。上下文切换过程中的信息被保存在进程控制块(PCB-Process Control Block)中。PCB 又被称作切换帧(SwitchFrame)。上下文切换的信息会一直被保存在 CPU 的内存中,直到被再次使用。
上下文切换包括了很多切换,主要分为以下几种
线程切换:同一进程中两个或以上线程之间进行切换。进程切换:两个或以上进程进行切换。模式切换:在给定线程中,用户模式与内核模式之间的切换。地址空间切换:将虚拟内存切换到物理内存。为什么需要线程、进程切换?
为了提高 cpu 的利用效率,以及提升用户体验,多进程可以让你在听网易云的时候打开微信和朋友聊天,多线程则是可以让你在听歌的时候还可以去看网易云的评论或者搜索其他歌曲。这就是多进程/多线程带给我们的好处。
由于多进程/多线程需要进行上下文切换才能实现,那上下文切换会带来什么问题呢?
增加了 cpu 的复杂度(需要保存和恢复上线程状态)。处理器高速缓存被重新加载。切换过于频繁可能会导致系统宕机。某些场景下频繁的上下文切换还会导致效率变低创建线程的方式有哪些?继承 Thread:继承 Thread 类并且重写 run() 方法,调用 start() 方法启动线程。
public class MyThread extends Thread {
}实现 Runnable 接口
定义一个实现 Runnable 接口的实现类,重写该接口的 run() 方法, 同时将该实现类的实例作为 Thread 实例化的参数,当调用 Thread 中 start() 方法时,启动线程。
public class MyThread2 implements Runnable {@Overridepublic void run() {System.out.println("当前线程:"+ Thread.currentThread().getName());}
}匿名内部类
匿名内部类其实也是实现了 Runnable 接口,并重写了 run() 方法,因为这个类没有名字,直接作为参数传递给 Thead 类。
public class MyThread3 {
}实现 Callable 接口
与使用 Runnable 相比, Callable 功能更强大些。
支持返回值。方法可以抛出异常。支持泛型。结合 FutureTask 可以获取到线程的返回值。FutureTask 同时实现了 Runnable, Future 接口。它既可以作为 Runnable 被线程执行,又可以作为 Future 得到 Callable 的返回值 public class MyThread4 implements Callable<String> {
}线程池启动
线程池创建线程是我们比较推荐使用的方式,理由如下:
重用存在的线程,减少对象创建、消亡的开销,性能佳。可有效控制最大并发线程数,提高系统资源的使用率,同时避免过多资源竞争,避免堵塞。提供定时执行、定期执行、单线程、并发数控制等功能。public class MyThread5 implements Runnable{
}线程的生命周期?了解线程的生命周期之前,我们需要知道线程的 6 种不同的状态,了解了它们,再来看线程的生命周期。
状态
描述
NEW
初始状态,线程被创建,但是还没有执行 start()方法。
RUNNABLE
运行状态(其实严格来分是两个状态:就绪状态、运行中状态),这是针对 JVM 层面的,对于 CPU 层面,调用了 start()方法并不会马上被执行,而是进入了就绪状态这个与线程的优先级有关,而 JVM 将就绪状态和运行中状态统称为运行中。
BLOCKED
阻塞状态,当前线程阻塞于锁
WAITING
等待状态,线程进入等待状态之后,需要等待其他线程完成之后做出通知,然后当前线程才有机会回到就绪状态,它自己无法结束等待状态
TIME_WAITING
超时等待状态,该状态不同于 WAITING ,它支持在指定时间后主动返回
TERMINATED
终止状态,表示当前线程已经执行完毕,线程将会被销毁
如果想了解 java 程序在运行时候线程状态之间的转换,可以使用 jstack 工具查看。
接下来我们再来看看线程的生命周期是什么样的?
图 2-1
Thread 类中的 start() 和 run() 方法有什么区别?start() 方法用于启动一个线程,让启动的线程处于就绪状态,等待 Cpu 的执行,而 run() 是线程真正执行的业务方法,run() 结束,线程也随之结束。
如果开启一个线程不是通过 start() 而是通过直接调用 run() ,那么只会在原来线程中执行,并不会开启一个新的线程,这点需要特别注意。
所以只有通过调用 start() 才能真正的创建一个新的线程。
sleep()、wait()、yield()方法有什么区别?等待池:假设一个线程 A 调用了某个对象的 wait()方法,线程 A 就会释放该对象的锁后,进入到了该对象的等待池,等待池中的线程不会去竞争该对象的锁。锁池:只有获取了对象的锁,线程才能执行对象的 synchronized 代码,对象的锁每次只有一个线程可以获得,其他线程只能在锁池中等待 sleep():Thread 中的静态方法,使当前线程进入指定时间的等待状态,该方法可以让让其他线程得到执行的机会,但是由于 sleep() 方法不会释放锁,只要是被 synchronized 修饰的方法或者代码块,其他线程仍然不可以访问。
wait():让线程进入等待状态,wait() 属于 Object 类中的方法,需要配合 notify() 及 notifyAll() 两个方法一起使用,这三个方法用于协调多个线程对共享数据的存取,所以必须在 synchronized 语句块内使用。但和 sleep() 不同的是 wait() 会释放锁标志,让其他线程可以访问被 synchronized 修饰的发放或者代码块,当调用 wait() 方法之后,当前线程将会暂停执行,并将当前线程放入对象等待池中,直到有线程调用了 notify() 及 notifyAll() 其中的一个或者等待时长超过了设置超时时间,被暂停的线程将会被恢复到锁池。需要注意的是 notify() 只会随机等待池中的一个线程进入锁池,而 notifyAll() 唤醒对象的等待池中的所有线程,进入锁池。
yield():让当前线程从运行状态进入就绪状态,从而可以让出 cpu 的执行权,应为就绪的线程能否被执行取决于线程的优先级(默认情况下),优先级高的线程被执行的概率较高,这并不能说明优先级低的线程就一定会比优先级高的线程后执行。执行 yield() 之后的线程也有可能会立马获取到 cpu 的执行权限,因为它处于就绪状态,所以说,虽然 yield() 让出了 cpu 的执行权,但是有可能因为当前现成的优先级很高,在竞争中又拿到了 cpu 的执行权,不要觉得执行了 yield() 的线程一定会让其他线程享有 cpu 执行权,他们全靠竞争。 yield() 只是让当前线程回到了和其他线程一样的起点。
终止线程的方法有哪些?正常结束:执行完 run() 方法主动结束。
stop()方法
由于 stop() 被标记为 @Deprecated ,说明当前方法已经不推荐使用,为什么不推荐使用呢?那是因为 stop() 属于暴力停止,他会强制当前线程结束自己的生命周期,这样会导致数据的不完整,也就是说会破坏程序的原子性,请看下面例子
public static void main(String[] args) throws InterruptedException {Thread thread = new Thread(() ->{System.out.println("当前线程:+"+Thread.currentThread().getName() +" 被执行了");try {sleep(10000);} catch (InterruptedException e) {e.printStackTrace();}System.out.println("当前线程:+"+Thread.currentThread().getName() +" 休眠结束了");});thread.start();Thread.sleep(3000);thread.stop();System.out.println("main 线程结束了");}在 main 函数中创建了一个子线程 thread,让子线程休眠 10 秒,然后在 main 函数休眠 3 秒之后调用子线程的 stop() 方法,这个时候 thread 线程会强制结束,控制台就不会输出 “休眠结束了”这句话,这也就会导致程序的不完整,可以将他理解为数据库中的事务,在我们正常的开发中,可能会造成数据的错误,这是一个很严重的问题,所以一定不要使用 stop() 停止线程。
退出标志法:
使用退出标识,使得线程正常退出,即当 run 方法完成后进程终止。我们在线程中创建一个线程安全的标志字段,线程中通过判断此字段来确定是否需要退出线程。
public class MarkStopThreadTest implements Runnable {
}STOP_FLAG:标志位,false 表示线程需要继续执行,true 表示当前线程需要结束了。
interrupt()方法:中断线程,这并不是代表当前线程会立马结束,这里只是修改了线程的 status ,线程什么时候结束,还是需要结束线程自己说了算。
当线程阻塞在调用方法时(wait()、join()、sleep()等),调用 interrupt()时,线程的终端状态将会被清除,会收到一个 InterruptedException 异常。
当线程阻塞在 IO 操作时, 通道将会被关闭,线程的中断状态将被设置,同时线程会收到一个 ClosedByInterruptException 异常。
当线程阻塞在 NIOjava.nio.channels.Selector(选择器)时,线程的中断状态将被设置,同时线程将会立即从选择器操作中返回,可能会带有一个非零的值,就像选择器的 java.nio.channels 调用了 java.nio.channels.Selector 的 wakeup 方法。
下面请看一个终止一个线程正在阻塞在 sleep()方法的 demo
public class StopThreadByInterrupt {
}当我们调用 interrupt() 的时候,sleep()会立马中断,然后继续往下执行,并没有直接将线程结束,所以一般结束线程的时候推荐使用 interrupt(),一面造成不可弥补的损失。
synchronized 与 Lock 有什么异同?归属:synchronized 属于 jdk 自带,而 Lock 属于 SDK 并发包中的内容。
用法:synchronized 可以在方法上、代码块中使用,而 Lock 必须要指定 锁定的起始和结束位置。
性能上:资源竞争较小时,synchronized 优于 Lock;相反资源竞争很大的时候,synchronized 的性能会下降的很快,Lock 完胜 。
功能性:synchronized 一个非公平锁, 可重入,但是不可中断的锁,而 Lock 支持可重入、可中断、可公平的锁。
功能
synchronized
Lock
中断性
✖
✔
可重入
✔
✔
可重试
✖
✔
自动释放锁
✔
✖
锁类型
可重入、不可中断、非公平锁
可重入、可中断、非公平锁(默认)、公平锁
锁定多个条件
✖
✔
什么是守护线程?JVM 一共提供了两种线程类型,一种是用户线程,另外一种就是守护线程。
用户线程:高优先级线程。程序中 JVM 会在终止之前等待任何用户线程完成其任务。守护线程:低优先级线程。其唯一作用是为用户线程提供服务。守护线程的作用是为用户线程提供服务,并且在用户线程运行的才会存在,java 程序启动时至少会存在一个 main 线程(用户线程),此时守护线程 也会被创建,main 线程又可以创建很多的用户线程,只要有用户线程还在运行,那么 JVM 就不会退出,当所有用户线程都已经执行完毕,包括 main 线程,JVM 就会立刻退出,守护线程也就结束了它们的生命周期。
正常来说守护线程是不会组织 JVM 的退出,但是如果因为设计的草率,在守护线程中调用 Thread.join(),这个时候守护线程就会阻止 JVM 退出。
守护线程在 JVM 运行中主要在处理垃圾回收任务、释放资源、后台支持、过期策略等等,我们知道。在我们开发中,我们并没有去写垃圾回收相关 的代码,就是因为这是由守护线程替我们完成的。
如何创建守护线程?
public static void main(String[] args) throws InterruptedException {Thread thread = new Thread(() ->{System.out.println("我是守护线程-----------"+Thread.currentThread().getName());System.out.println("是否为守护线程:"+ Thread.currentThread().isDaemon());});//设置当前线程为守护线程 thread.setDaemon(true);thread.start();Thread.sleep(1000);System.out.println("结束");}Java 内存模型是什么?并发编程中,同步和通信这两个问题是需要处理的关键问题。
通信:在共享内存模型中,线程之间共享程序的公共状态,通过写-读内存中的公共状态进行隐式通信。在消息传递的并发模型中,线程之间没有公共状态,线程之间必须通过发送消息来显式的通信。
同步:线程同步指程序中用于控制不同线程之间操作发生相对顺序的机制,程序必须需要显式的指定某个方法或者某个代码块在线程之间互斥执行。在消息传递的并发模型中,由于消息的发送必须在消息接收之前,因此同步时隐式进行的。
什么是 java 内存模型?
java 线程之间的通信由 java 内存模型(JMM)控制,JMM 决定一个线程对共享变量的写入如何使对另一个线程可见。从抽象的角度来看,JMM 定义了线程和主内存之间的抽象关系:线程之间的共享变量存储在主内存中,每个线程都有一个私有的本内存。本地内存中存储了该线程以读/写共享变量的副本。本地内存是 JMM 的一个抽象概念,并不真实存在。它涵盖了缓存、写缓冲区、寄存器以及其他的硬件和编译器优化。
图 2-2
从图形中我们可以看到,如果线程 A 与线程 B 之间需要通信,那么必须要经过以下两个步骤。
1:线程 A 把本地内存 A 中更新过的共享变量刷新到主内存中。
2:线程 B 到主内存中读取线程 A 之前已经修改过的共享变量。
本地内存 A、B 都只会存储主内存中共享变量的副本,然后线程 A、B 各自对副本中的变量进行操作,最后将副本中的数据刷新到主内存中,这就是完整的一条流程。
在多线程横行的时代,这两个步骤按照目前这样走法,肯定是存在线程安全问题的,所以这里就要涉及到并发编程 BUG 来源:原子性、可见性、有序性(后面会讲到),这也是 java 内存模型中最重要的内容之一了。
什么是原子性、可见性和有序性?原子性:一个或多个操作要么全部执行成功,要么全部不执行,我们称这种为原子性,把它当作是一个最小的执行单位,这样就不会发生线程安全问题。
线程切换导致的原子性问题。
早期时代,发明了多进程,让用户可以一边听歌一边敲代码,操作系统选择一个进程执行一段时间之后(假设 20 毫秒),操作系统就会选择另外一个进行来进行操作,我们成这种行为为 “任务切换”,20 毫秒称为 “时间片”。
由于进程之间变量不共享,所以也就不会存在并发的问题,但是 java 的执行单位是线程,和进程一样,也会伴随着任务切换(我们称它为上下文切换),但是由于线程属于同一个进程中,而进程中变量共享,所以这个时候,任务切换就成了并发编程诡异 BUG 的源头之一了。
如:i+=1 这一条简单的 +1 操作,对于 cpu 而言,需要 3 条执行才能完成。
指令 1:把共享变量 i 加载到 cpu 的寄存器。
指令 2:在寄存器中执行 +1 操作。
指令 3:将结果写主内存(由于缓存机制的存在,此时写回的可能是 cpu 缓存而不是主内存)。
假设 i 初始值为 0,线程 A、B 同时执行 +1 操作,由于任务切换的存在,线程 A 执行了指令 1(获取到 i = 0)、指令 2(i = 1,还没写回到内存中),此时,cpu 将执行权切换到了线程 B,执行了指令 1(获取到 i = 0,因为线程 A 并没有将 i=1 写回主内存)、指令 2(i = 1),指令 3,最后 cpu 切回线程 A ,执行指令 3,这样,bug 就出现了, i 的最终结果是 1,而不是 2.
如何保证程序的原子性?
1.锁:顾名思义,将需要原子性的操作通过加锁的方式来保证原子性(比如 java 中的 synchronized)。2.原子类工具:java 并发工具包中提供了很多原子类的工具类,实现原理是利用 CAS 保证共享变量的安全性(比如:AtomicInteger、AtomicILong、AtomicBoolen)。可见性:当一个线程修改了共享变量时,另一个线程可以读取到这个修改后的值。这在单核时代中没有任何问题,随着技术的提升,双核、四核、八核 CPU 的出现,让并发编程也开始出现了大量的问题,比如,共享变量的可见性问题。
缓存导致共享变量的可见性问题:在 java 内存模型中我们可以知道每个线程都有自己的一个工作内存,他并不是真实存在的,你可以理解为 CPU 的高级缓存,而多核时代,就代表着有多个高级缓存,这样其中一核修修改了数据但是还没刷新到主内存中时,其他线程从主内存获取的共享变量就是旧的,而拿不到修改后的数据,这就是可见性问题。
如何保证线程的可见性:
共享变量增加 volatile 关键字。程序加锁-synchronized。将共享变量设置为 final。有序性
高级编程语言中,编译器在编译阶段,往往会对我们写的代码进行优化,其中就包括指令重排,指令重排是指编译器核处理器为了优化程序性能而对执行序列进行重新排序的一种手段。
但是指令重排不能影响程序的最终结果,在单核时代,确实没有问题,但是多核时代,诡异 BUG 就来了。
public class RestTemplateConfig {static RestTemplate restTemplate;public RestTemplate getRestTemplate(){if(null == restTemplate){synchronized (RestTemplateConfig.class){if(null == restTemplate()){return new RestTemplate();}}}return restTemplate;}}这是一个简单单例的获取方式,双重校验的方式,这么一看确实没有什么问题,那究竟会发生什么呢?
在获取 getRestTemplate() 的方法中,我们首先判断 restTemplate 是否为空,空着锁定 RestTemplateConfig.class,然后创建一个新的 RestTemplate 实例,看上去无懈可击,但是聪明的人通过上面的标题可能已经知道问题出现在哪里了。
假设 两个线程 A、B,同时调用 getRestTemplate() 方法,判断 null == restTemplate 都为空,同时加锁,A 成功、B 等待,此时线程 A 创建了一个新的 RestTemplate 实例并且返回,然后释放锁,线程 B 获取锁,判断实例已经存在,释放锁直接返回实例。
创建 RestTemplate 实例,我们期望的步骤是这样的:
1.分配一块内存 M。2.在内存 M 上 初始化 RestTemplate 对象。3.然后将 M 的地址赋值给 restTemplate 变量。但是由于指令重排的存在,创建 RestTemplate 实例,执行步骤可能是这样的:
1.分配一块内存 M2.将 M 的地址赋值给 restTemplate 变量;3.最后在内存 M 上初始化 RestTemplate 对象。这也就会导致线程 A 在初始化 RestTemplate 实例 之后,先赋值了 restTemplate 变量,此时发生了线程切换,来到了线程 C ,线程 C 在第一个判断中就发现变量
restTemplate 有值,不需要去进行加锁创建对象的操作,直接返回,这样就会导致栈中的变量 restTemplate 有值,但是堆上的对象还没有被创建,从而抛出空指针异常。
不过这个问题在 JDK 1.6 就被修复了,这里只是举了一个例子,用来说明指令重排也是造成多线程诡异 BUG 之一,不可轻视。
解决这个问题其实也挺简单,只要禁止指令重排即可。
什么是 happens-before 规则?在 JMM(JSR-33)中,如果一个操作执行的结果需要对另一个操作可见,那么这两个操作之间必须要存在 happens-before 关系。这里提到的两个操作既可以是在一个线程之内,也可以是在不同线程之间, happens-before 就是为了解决线程可见问题。
happens-before 规则如下:
顺序规则:一个线程中的每个操作, happens-before 于该线程中的任意后续操作。监视器锁规则:对一个锁得解锁, happens-before 于随后对这个锁的加锁。volatile 变量规则:对一个 volatile 域的写,happens-before 域任意后续对这个 volatile 域的读。传递性:如果 A happens-before B ,且 B happens-before C ,那么 A happens-before C。这 4 调规则 你可以记不住,但是这句话一定要理解:happens-before 保证了前面一个操作的结果对后续操作是可见的。
图 2-3
如图 2-3 所示,一个 happens-before 规则对应于一个或多个编译器核处理器重排序规则。对于 java 程序员来说,happens-before 规则很简单,他避免 java 程序员为了理解 JMM 提供的内存可见性保证而去学习复杂的重排序规则以及这些规则的具体实现方法。
简单介绍一下 volatile 变量是什么?volatile 特性
一个 volatile 变量的单个读/写操作与一个普通变量的读/写操作都是用同一个同步锁,执行的效果是一样的。
锁的 happens-before 规则保证锁的释放和获取锁的两个线程之间内存可见,这意味着对一个 volatile 变量的读,总是能看到任意线程对这个 volatile 变量的最后的写入。
只要是变量被 volatile 修饰,对该变量的读/写就具有原子性。如果是多个 volatile 操作或类似 volatile++ 这种符合操作,无法保证原子性。
总体来说,volatile 具备以下两个特性:
可见性:对一个 volatile 变量的读,总是能看到任意线程对这个 volatile 变量最后的写入。原子性:对任意单个 volatile 变量的读/写具有原子性,但类似 volatile++ 这种符合操作不具备原子操作。volatile 和 happens-before 的关系
自从 JSR-133 开始,volatile 变量的写 - 读可以实现线程之间的通信。
volatile 的写 - 读 于锁的释放 - 获取有着相同的内存效果:volatile 写和锁的释放具有相同的内存语义;volatile 读于锁的获取有相同的内存语义。
这段可以结合 happens-before 规则一起看,效果更佳。
总体来说,volatile 可以保证变量的内存可见性,禁止指令重排。
如何在两个线程间共享数据?如果执行的是相同代码,那么可以通过使用同一个 Runnable 对象,对象中的属性共享。
如果执行的是不同代码,将共享数据封装到一个独立的对象中,然后将这个对象传递给不同的线程,所有对数据的操作都在数据封装的对象中,这样也能实现线程之间的数据共享。
将 Runnable 对象作为一个类的内部类,共享数据作为这个类的成员变量,每个线程对共享数据的操作方法也封装在外部类,以便实现对数据的各个操作的同步和互斥,作为内部类的各个 Runnable 对象调用外部类的这些方法。
Java 中 notify 和 notifyAll 有什么区别?这里还是需要结合前面讲到的两个概念:锁池与等待池。
如果线程调用了对象的 wait()方法,那么线程便会处于该对象的等待池中,等待池中的线程不会去竞争该对象的锁。
但是当线程调用了对象的 notify() 或者 notifyAll() 之后,被唤醒的线程将会进入该对象的锁池,锁池中的线程会去竞争该对象的锁。,也就是说,调用了 notify() 后只有一个线程会由等待池进入锁池,而 notifyAll 会将该对象等待池内的所有线程移动到锁池中,等待锁竞争。
一般 notify() 与 notifyAll() 都是和 wait() 一起使用,我们称之为:等待/通知机制。
一个线程 A 调用了对象 O 的 wait() 方法进入等待状态(进入等待池),而另一个线程 B 调用了对象 O 的 notify() 或 notifyAll() 方法,线程 A 收到通知后从对象 O 的 wait() 方法中返回(进入锁池,如果有多个线程,这里会涉及到锁竞争),进而继续执行后续操作。上述两个线程通过对象 O 来完成交互,而对象上的 wait()、notify()、notifyAll() 的关系就如同开关信号一样,用来完成等待方会和通知方之间的交互工作。
为什么 wait, notify 和 notifyAll 这些方法不在 thread 类里面?由于 wait,notify() 和 notifyAll()都是锁级别的操作,所以把他们定义在 Object 类中,因为锁属于对象。
什么是线程池? 为什么要使用它?每个线程都有自己的生命周期,并且线程属于 cpu 级别的指令,比较重,创建和销毁比较耗时,如果我们在程序启动的时候就创建好一些线程,我们需要使用的时候直接使用即可,省去了线程的创建节约了时间,也提升了效率,spring 就提供了这样的功能,没错,就是线程池。
线程池的核心参数
corePoolSize:核心线程数,核心线程会一直存活,即使没有任务需要执行,当线程数小于核心线程数时,即使有线程空闲,线程池也会优先创建新线程处理,设置 allowCoreThreadTimeout=true(默认 false)时,核心线程会超时关闭。
queueCapacity:任务队列容量(阻塞队列),当核心线程数达到最大时,新任务会放在队列中排队等待执行(队列先进先出)。
maxPoolSize:最大线程数:当线程数>=corePoolSize,且任务队列已满时。线程池会创建新线程来处理任务;当线程数=maxPoolSize,且任务队列已满时,线程池会拒绝处理任务而抛出异常。
keepAliveTime:线程空闲时间,当线程空闲时间达到 keepAliveTime 时,线程会退出,直到线程数量=corePoolSize,如果 allowCoreThreadTimeout=true,则会直到线程数量=0。
allowCoreThreadTimeout:允许核心线程超时。
rejectedExecutionHandler:拒绝策略,当线程池的任务缓存队列已满并且线程池中的线程数目达到 maximumPoolSize 时,如果还有任务到来就会采取任务拒绝策略,主要有四种拒绝策略
ThreadPoolExecutor.AbortPolicy:丢弃任务并抛出 RejectedExecutionException 异常。ThreadPoolExecutor.DiscardPolicy:务丢弃任,但是不抛出异常。ThreadPoolExecutor.DiscardOldestPolicy:丢弃队列最前面的任务,然后重新提交被拒绝的任务 ThreadPoolExecutor.CallerRunsPolicy:由调用线程(提交任务的线程)处理该任务 java 常见的线程池
newCachedThreadPool:创建一个可缓存线程池,如果线程池长度超过处理需要,可灵活回收空闲线程,若无可回收,则新建线程。
newFixedThreadPool:创建一个指定工作线程数量的线程池,每当提交一个任务就创建一个工作线程,如果工作线程数量达到线程池初始的最大数,则将提交的任务存入到池队列中。该线程在提升效率和节省线程创建时间有很大优势,但它同时也存在不足点,当线程池空闲时,即使线程池中没有任务正在执行,该线程池也不会释放工作线程,会消耗额外的资源。
newSingleThreadExecutor:创建一个单线程化的 Executor,顾名思义,该线程池只会创建一个工作线程来执行任务,保证所有的任务都按照指定顺序(FIFO, LIFO, 优先级)执行。如果在执行过程中,线程发生异常终止,那会将会有一个新的线程代替它,继续保证顺序执行,这也是单个线程的优势(保证线程执行的顺序行)。
newScheduleThreadPool:创建一个支持定时以及周期性执行任务的线程池,支持定时及周期性任务执行。
线程池应用场景
日志记录。发送通知类业务。持久化。批量数据处理。肖峰(mq 会更合适)线程池注意事项
多线程容易出现死锁(这并不是线程池才会出现,只要有多线程就可能会存在)。任务队列尽量选择有界队列,无界队列在极端条件下容易造成 OOM。bug 诡异,追踪复杂编写多线程程序时一定要考虑边界值问题。异步转同步问题。什么是 cas?概念 比较并交换(compare and swap, CAS),是原子操作的一种,可用于在多线程编程中实现不被打断的数据交换操作,从而避免多线程同时改写 某一数据时由于执行顺序不确定性以及中断的不可预知性产生的数据不一致问题。 该操作通过将内存中的值与指定数据进行比较,当数值一样 时将内存中的数据替换为新的值。
CAS 是原子操作,可以保证线程的安全,但是无法保证线程的同步,它是 CPU 的一条指令,非阻塞、轻量级的乐观锁。
原理 CAS 的核心就是比较和交换两个动作, 内存 中存放着一个变量 V,我们在对这个变量进行修改时,会有两个操作,一个是修改前的旧值(期望值:期望和内存中的值一致),另外一个是新值(修改后的值),进行修改的时候,cas 会 先判断旧值(期望值)和内存中目前存在的值是否相同,如果相同,则将内存中的值修改为新值,如果不相等,则说明该变量已经被其他线程修改,当前修改线程需要自旋(也就是循环-死循环),自旋的时候会将内存中的值重新取出来,直到期望值和内存中相同,再执行交换动作,也就是将新值写道内存中。
java cas 实现原理源码
public final int getAndAddInt(Object var1, long var2, int var4) {int var5;do {var5 = this.getIntVolatile(var1, var2);} while(!this.compareAndSwapInt(var1, var2, var5, var5 + var4));
cas 无锁只是相对于编程语言的,对 CPU 而言,cas 同样存在锁机制,不然,两步操作有先后顺序,多线程的时候肯定存在并发问题,java cas 原子性是由 Unsafe 保证的,而他是 C++ 级别的。
cas 缺点
一、自旋浪费 cpu 资源。如果一个共享变量可能会被经常修改,比如双十一的商品库存,可能一秒钟都会改变几万次甚至几十万次,这个时候使用 cas 就会适得其反。
二、ABA 问题。将设内存中的变量初始值 V = 10,有三个线程同时对它进行修改操作,进行第一步的时候三个线程获取到的值都是 10,线程一首先进行对比、交换成功,将 V 改成了 20,此时线程 2 在进行比较的时候发现对不上了,所以获取到了新值 V = 20 ,在将值修改成了 10,这是切换到线程 3,他的期望值也是 10 ,与内存中的值进行对比,发现相同,判定符合期望,然后将新值 15 替换到内存中,这就是 cas 可能会出现的 ABA 问题。这个问题说大不大,说小也不小,有些业务可能会造成不可挽回的损失,所以在使用 cas 的时候一定要注意。
如何解决 ABA 问题
解决 ABA 问题其实很简单,多引入一个参数即可,版本号,每次修改版本号+1,判断值是否相同的同时还需要判断版本号是否对的上,这样就能很好的解决 cas 中 ABA 的问题,java 提供了需要解决 ABA 问题的 sdk 并发包,例如:AtomicStampedReference、AtomicMarkableReference 等等。
谈谈你对 AQS 的理解
AbstractQueuedSynchronizer:提供一个框架来实现依赖于先进先出 (FIFO) 等待队列的阻塞锁和相关的同步器(信号量、事件等)。此类旨在为大多数依赖单个原子 int 值表示状态的同步器提供有用的基础。子类必须定义更改此状态的受保护方法,并定义该状态在获取或释放此对象方面的含义。鉴于这些,此类中的其他方法执行所有排队和阻塞机制。子类可以维护其他状态字段,但只有 int 使用方法操作的原子更新值 getState()、setState(int)、compareAndSetState(int, int)在同步方面被跟踪。
子类应定义为非公共内部帮助类,用于实现其封闭类的同步属性。类 AbstractQueuedSynchronizer 不实现任何同步接口。相反,它定义了一些方法,例如 acquireInterruptibly(int) 可以由具体的锁和相关的同步器适当调用以实现它们的公共方法。
此类支持默认独占 模式和共享模式中的一种或两种。以独占模式获取时,其他线程尝试获取时不会成功。多个线程的共享模式获取可能(但不一定)成功。此类不“理解”这些差异,除非在机械意义上,当共享模式获取成功时,下一个等待线程(如果存在)也必须确定它是否也可以获取。不同模式下等待的线程共享同一个 FIFO 队列。通常,实现子类只支持其中一种模式,但两者都可以发挥作用,例如在 ReadWriteLock . 只支持独占或只支持共享模式的子类不需要定义支持未使用模式的方法。
此类定义了一个嵌套 AbstractQueuedSynchronizer.ConditionObject 类,可以由支持独占模式的子类用作 Condition 实现,该方法 isHeldExclusively() 报告是否针对当前线程独占保持同步,release(int) 使用当前值调用的方法 getState()完全释放此对象,并且 acquire(int),给定此保存状态值,最终将此对象恢复到其先前获得的状态。没有 AbstractQueuedSynchronizer 其他方法会创建这样的条件,因此如果无法满足此约束,请不要使用它。的行为 AbstractQueuedSynchronizer.ConditionObject 当然取决于其同步器实现的语义。
此类为内部队列提供检查、检测和监视方法,以及用于条件对象的类似方法。这些可以根据需要使用 AbstractQueuedSynchronizer 它们的同步机制导出到类中。
此类的序列化仅存储底层原子整数维护状态,因此反序列化对象具有空线程队列。需要可序列化的典型子类将定义一个 readObject 方法,该方法在反序列化时将其恢复到已知的初始状态。
什么是 ThreadLocal?ThreadLocal 是 Thread 的局部变量,用于编多线程程序,对解决多线程程序的并发问题有一定的启示作用。
概念很抽象,其实只要记住一句话即可,为每一个线程创建一个变量,线程之间变量不共享,变量的生命周期与线程的生命周期一致,可以参考方法中的局部变量,没有竞争,所以不会存在线程安全问题,所以在并发编程中,ThreadLocal 被广泛的使用。
我们如何使用 ThreadLocal 呢?
ThreadLocal 构造允许我们存储只能由特定线程访问的数据。假设我现在需要在某个线程中使用一个整形的变量 threadLocal,我们只需要这样设计即可:
ThreadLocal<Integer> threadLocal = new ThreadLocal<>();num 已经初始化完成,那我们应该如何使用呢?方法很简单, ThreadLocal 为我们提供了两个操作方法:get()、set() 方法,ThreadLocal 会以线程为单位操作 num 变量。
threadLocal.set(1);Integer result = threadLocal.get();如果我们希望在初始化的时候就能给 threadLocal 赋上初始值,那么我们只需要借助 ThreadLocal 的静态方法:withInitial() 即可实现(jdk 8 以上)。
ThreadLocal<Integer> threadLocal = ThreadLocal.withInitial(() -> 100);上面代码表示初始化 threadLocal 并且将 threadLocal 赋值为 100。
源码理解
public void set(T value) {Thread t = Thread.currentThread();ThreadLocalMap map = getMap(t);if (map != null)map.set(this, value);elsecreateMap(t, value);}第一步获取当前线程,然后使用当前线程作为参数查询 ThreadLocalMap 如果存在,执行赋值操作,如果不存在,实行初始化创建操作。
void createMap(Thread t, T firstValue) {t.threadLocals = new ThreadLocalMap(this, firstValue);}
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);}
static class Entry extends WeakReference<ThreadLocal<?>> { //WeakReference 弱引用/** The value associated with this ThreadLocal. */Object value;
createMap(Thread t, T firstValue) 初始化了一个 ThreadLocalMap,key 为 ThreadLocal value 为线程的变量值。但是 ThreadLocalMap 并不是归 ThreadLocal 持有,而是存在于 Thread 中。
很明显 table 才是 ThreadLocal 的核心,存放着 ThreadLocal 的变量,通过 Entry 的源码我们可以知道,Entry 对 ThreadLocal 对象的引用为弱引用,正常来说,ThreadLocal 生命周期结束,Entry 也会被回收,但是事实上却不是这样的,可以看下面的内存泄漏问题。
为什么不将 ThreadLocalMap 归为 ThreadLocal 持有?
ThreadLocal 仅仅只是一个工具类,内部并不持有任何和线程相关的数据,所有和线程相关的数据都会存储在 Thread 中。不容易产生内存泄漏。如果 ThreadLocal 持有 ThreadLocalMap,ThreadLocalMap 会持有 Thread 对象的引用,所以只要 ThreadLocal 对象存在,那么 ThreadLocalMap 对象中的 Thread 对象将永远无法回收,而 ThreadLocal 的生命周期明显长于 Thread,所以很容易造成内存泄漏。ThreadLocal 与内存泄露
真实开发中,一般都是使用线程池,这个时候,线程使用完成之后不会马上回收,尤其是核心线程,这就会导致 Thread 对象中的 ThreadLocalMap 也不会被回收,即使 ThreadLocalMap 中的 Entry 对 ThreadLocal 是弱引用(WeakReference),ThreadLocal 生命周期结束就会被回收,但是 Entry 中的 Value 却是被 Entry 强引用的,就算 value 的生命周期结束,也无法回收。
如何解决内存泄露问题
手动释放,添加 try、finally 代码块,在 finally 中调用 ThreadLocal 对象中的 remove() 进行手动释放。
死锁是什么?如何避免死锁?死锁(英语:deadlock),又译为死结,计算机科学名词。当两个以上的运算单元,双方都在等待对方停止执行,以获取系统资源,但是没有一方提前退出时,就称为死锁。在多任务操作系统中,操作系统为了协调不同线程,能否获取系统资源时,为了让系统正常运作,必须要解决这个问题。另一种相似的情况称为“活锁”。
举例:线程 1 操作为:用户 A 给用户 B 转账 100 元,线程 2 操作为 用户 B 给用户 A 转账 50 元,为了线程安全问题,线程 1 需要先锁住用户 A 的账户 然后锁住用户 B 的 操作,执行转账功能(A 用户减 100 ,用户 B 加 100),不巧的这个时候 线程 2 先锁住了 B 的账户,同时需要获取用户 A 的锁,这个时候就发生了这样的一件事情,线程 1 等待 线程 2 释放 用户 B 的账户锁,线程 2 等待线程 1 释放 用户 A 的锁,如果没有人为干预,那么他们将会等到海枯石烂,抱憾终生,这就是死锁。
虽然死锁的后果很严重,但是产生死锁的条件很苛刻,我们只要注意好以下几点,死锁并不可怕。
死锁产生的条件
互斥条件:指进程(线程)对所分配到的资源进行排它性使用,即在一段时间内某资源只由一个进程(线程)占用。如果此时还有其它进程(线程)请求资源,则请求者只能等待,直至占有资源的进程用毕释放。请求和保持条件:指进程(线程)已经保持至少一个资源,但又提出了新的资源请求,而该资源已被其它进程(线程)占有,此时请求进程(线程)阻塞,但又对自己已获得的其它资源保持不放。不剥夺条件:指进程(线程)已获得的资源,在未使用完之前,不能被剥夺,只能在使用完时由自己释放。环路等待条件:指在发生死锁时,必然存在一个进程(线程)——资源的环形链,即进程集合{P0,P1,P2,···,Pn}中的 P0 正在等待一个 P1 占用的资源;P1 正在等待 P2 占用的资源,……,Pn 正在等待已被 P0 占用的资源。死锁必须满足上述四个条件才会触发,如果我们想要预防死锁,只要破坏以上 4 个条件中的人一个即可。
系统也可以尝试回避死锁。因为在理论上,死锁总是可能产生的,所以操作系统尝试监视所有进程,使其没有死锁。
死锁发生后如何消除死锁
重启进程,这个方法简单粗暴,但是重启带来的停服问题可能会带来较大的损失(可以采用多节点解决重启停服的问题)。
将进程回滚到先前的某个状态,如果一个进程被多次回滚,迟迟不能占用必需的系统资源,可能会导致资源匮乏。
什么是活锁?
活锁(livelock),与死锁相似,死锁是行程都在等待对方先释放资源;活锁则是行程彼此释放资源又同时占用对方释放的资源。当此情况持续发生时,尽管资源的状态不断改变,但每个行程都无法获取所需资源,使得事情没有任何进展。
举例:
你骑车上班,快迟到了,决定超小道,经过一个胡同的时候,出现了一个精神小伙,同样骑着小黄车,你们相遇了,但是你们的脾气都很爆,谁也不想让谁,都想让对方让自己先通过,然后你上班就迟到了,这就是死锁。
第二天,你又经过了这个胡同,又遇见了他,但是你吸取到昨天的教训,你想让对方先过,可能是你们昨天都给彼此留下了深刻印象,他也主动的给你让道,你们同时让道,但可惜的是你们礼让的方向都是同一侧,然后你们继续调整礼让位置,不巧的是你们又碰到了同一侧,然后你们就会一直这样让下去,所以你上班又迟到了,这个就是活锁。
请介绍一下 synchronized 锁升级要搞明白什么是 synchronized 锁升级,就得先搞清楚什么是 synchronized?前面有介绍,可以往前面翻。
当我们聊到 synchronized 的时候,很多人第一反应就是他是一个重量级锁,没错,这也是为什么 JDK 团队在 1.6 对 synchronized 做了大量的优化,其中就包括 synchronized 的锁升级。
对象头
对象头,也就是我们经常说到的 mark Word,而我们的 synchronized 使用的锁就是存放 java 对象头中。如果对象是数组类型,则虚拟机用 3 个字宽存储对象头,如果是非数组类型,则用 2 字宽存储对象头。在 32 位虚拟机中, 1 字宽等于 4 字节(32 bit)。
java 对象头的长度
长度
内容
说明
32/64 bit
Mark Word
存储对象的 hashCode 或锁信息等
32/64 bit
Class Metadata Address
存储对象类型数据的指针
32/32 bit
Array length
数组的长度(如果对象类型是数组)
java 对象头里的 Mark Word 默认存储对象的 HashCode、分代年龄和锁标记位。32 位 JVM 的 Mark Word 的默认存储结构如下所示
锁状态
25 bit
4 bit
1 bit 是否是偏向锁
2 bit 锁标志位
无锁状态
对象的 hashCode
对象分代年龄
0
01
在运行中, Mark Word 存储的数据会随着锁标志位的变化而发生变化,具体如下图所示。
而 64 位虚拟机则于 32 位 的 Mark Word 是有着一些差别的,Mark Word 在 64 位下所占空间为 64 bit。
Mark Word 搞明白之后我们需要弄明白锁升级中的几个锁状态:无锁状态、偏向锁、轻量级锁、重量级锁。
无锁状态:顾名思义,没有加锁,这个没啥需要解释的。
偏向锁:当一个线程访问同步代码块并获取到锁时,会在对象头(Mark Word)和栈帧中的锁记录里存储锁偏向的线程 ID,该线程再次进入和退出同步代码块时不需要进行 CAS 操作来加锁和解锁,只需要简单的判断一下对象头(Mark Word)里是否存储者指向当前线程的偏向锁。如果是,表示该线程已经获取到了锁。如果不是,则需要再测试一下 Mark Word 中偏向锁的标志(偏向模式)是否设置为 1(表示当前是偏向锁),如果没有,则利用 CAS 竞争锁;如果是,则尝试使用 CAS 将对象头的偏向锁指向当前线程。
偏向锁的撤销:偏向锁使用了一种等待竞争才会释放锁的机制,所以偏向锁的撤销只有其他线程尝试竞争偏向锁的时候,只有偏向锁的线程才会释放锁。但需要注意的是:偏向锁的撤销需要一个全局安全点(所有线程都停止执行),它会先暂停持有偏向锁的线程,然后检查持有偏向锁的线程是否处于存活状态,如果该线程处于非活动状态,则立即结束偏向锁,根据锁对象目前是否处于被被锁的状态是否撤销偏向锁,撤销后标志位恢复到无锁状态或者轻量级锁状态。
偏向锁可以提高带用同步但无竞争的程序性能,但他同样是一把双刃剑,如果程序中大部分的锁都会被多个线程访问,那偏向锁可能会适得其反,所以需要根据程序的自身条件判断是否需要开启偏向锁。
偏向锁在 JDK 6、7 是自动开启的,如果你想关闭偏向锁,只需要将 JVM 的参数:-XX:-UseBiasedLocking=false,这样程序默认会进入轻量级锁状态。
轻量级锁:在线程即将进入同步代码块时,如果此同步对象没有被锁定(锁标志位为 ‘’01‘’),虚拟机首先会在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,利用存储锁对象目前 Mark Word 的拷贝(官方称之为:Displaced Mark Word)。随后虚拟机将会使用 CAS 操作尝试将对象的 Mark Word 更新为指向 Lock Record 的指针。如果操作成功,说明当前线程已经拥有了这个对象的锁,并且对象的 Mark Word 锁标志将会修改为 “00”,表示此对象处于轻量级锁定状态,如果失败,那说明至少存在一个线程与当前线程竞争获取该对象的锁。虚拟机会优先检查对象的 Mark Word 是否指向当前线程的栈帧,如果是:说明线程已经拥有了这个对象的锁,直接执行代码块即可,否则说明对象已经被其他线程占用了,此时轻量级锁会膨胀成为重量级锁(轻量级的加锁)。
轻量级的解锁:如果对象的 Mark Word 还是指向线程的锁记录,则会使用 CAS 操作将当前对象的 Mark Word 和线程中复制的 Displaced Mark Word 进行替换,替换成功,说明同步过程顺利完成;替换失败,说明其他线程尝试获取过该锁,就需要释放锁的同时,唤醒被挂起的其他线程。
轻量级锁的不足:如果长时间得不到锁,CAS 操作会频繁的消耗 CPU。
重量级锁:这个大家也是非常熟悉,所有线程互斥,获取到锁的线程执行,其他线程阻塞被挂起,效率较差。
锁升级的流程
所以撤销偏向锁的时候标志位不一定会变成无锁,也有可能会升级为轻量级锁(存在锁竞争时)。
偏向锁、轻量级锁、重量级锁的优缺点对比
锁
优点
缺点/不足
应用场景
偏向锁
加锁/解锁不需要额外的消耗
如果线程存在锁竞争,会带来额外的锁撤销操作
只有一个线程访问的同步场景
轻量级锁
竞争的线程不会阻塞,提高了程序的响应速度
如果较长时间获取不到锁,CAS 自旋会大大增加 CPU 的消耗
追求响应时间、同步代码块执行速度非常快
重量级锁
线程竞争无需 CAS 自旋,不会消耗 CPU 资源
线程阻塞,响应缓慢
追求吞吐量、同步代码块执行较长
结语:
最后,我把我面试前几个月准备复习攻克的所有面试题已整理成文档,需要获取的小伙伴可以+ VX: mxk6072
大致内容包括了: 各类大小厂面经真题、Java 八股文集合、JVM、多线程、并发编程、设计模式、Java、MyBatis、ZooKeeper、Dubbo、Elasticsearch、Memcached、MongoDB、Redis、MySQL、RabbitMQ、Kafka、Linux、Netty、Tomcat、spring 面试题、spring cloud 面试题、spring boot 面试题、spring 教程 ?
感谢大家的阅读,希望对大家有帮助,早日拿到大厂 offer
评论