多线程与并发编程
阅读提示
并发题建议按“锁 -> 可见性 -> 原子性 -> 线程池”顺序复习。
每道题尽量补一句“线上场景”或“性能权衡”,会更像真实工程实践。
synchronized的用法
1、synchronized的用法
1. 修饰实例方法
当synchronized用于修饰一个实例方法时,该方法被称为同步方法。这意味着在同一时刻,只有一个线程可以进入该方法的代码块。这种同步是基于对象的实例锁,即每个对象都有一个锁,当线程进入一个对象的同步方法时,它必须先获得该对象的锁。如果另一个线程已经持有该对象的锁,则该线程必须等待,直到锁被释放。
public synchronized void syncMethod() {
// method body
}2. 修饰静态方法
修饰静态方法时,同步是基于类对象(Class对象)的锁。这意味着所有实例共享同一把锁。当一个线程进入一个静态的同步方法时,它获得的是该类的锁。如果其他线程已经持有该类的锁,则新来的线程必须等待。
public static synchronized void staticSyncMethod() {
// method body
}3. 修饰代码块
当synchronized用于修饰一个代码块时,它允许你指定一个锁对象。这种方式提供了更细粒度的控制,因为它允许你只同步访问特定资源和数据部分的代码。代码块中的代码在执行时必须持有指定的锁对象。
public void someMethod() {
Object lock = new Object();
synchronized(lock) {
// code block
}
}2、锁升级
(1)、内存布局 (对象的内存布局)

在 Java 中,每个对象的内存布局是由 Java 虚拟机(JVM)的具体实现决定的,例如 Oracle HotSpot JVM。这里,我们可以讨论 HotSpot JVM 中普通(非数组)对象的典型内存布局。对象的内存布局通常包括三部分:对象头(Header)、实例数据(Instance Data)和对齐填充(Padding)。
1. 对象头(Object Header)
对象头包含了对象自身运行时的一些必要信息,包括两部分:标记字(Mark Word)和类型指针。
- Mark Word:这部分通常占用 8 字节,用于存储对象自身的运行时数据,如哈希码、锁信息(锁状态标记、线程持有的锁等)、垃圾收集信息(如年龄、是否存活等)。
- Class Pointer:这部分通常也占用 4 字节(默认情况下)
2. 实例数据(Instance Data)
实例数据部分占用的字节数取决于对象的具体字段。Java 数据类型的字节如下:
boolean和byte:1 字节char和short:2 字节int和float:4 字节long和double:8 字节- 引用类型(如对象引用):在 64 位 JVM 中通常是 4 字节(使用压缩指针时)或 8 字节。
字段的排列可能会由 JVM 进行优化,以减少由于内存对齐引起的空间浪费。
3. 对齐填充(Padding)
为了使对象的总大小是 8 字节的倍数(这是大多数现代计算机系统内存寻址的最小单位),JVM 可能会在实例数据之后添加一些填充字节。这样做是为了确保内存对齐,有助于提高 CPU 缓存的效率。
示例
考虑一个简单的 Java 对象:
public class Example {
int a; // 4 字节
boolean b; // 1 字节
}在这个例子中,对象的内存布局可能如下:
- 对象头:12 字节(压缩类指针)或 16 字节(非压缩)
- 实例数据:
int4 字节 +boolean1 字节 = 5 字节 - 对齐填充:为了使总大小为 8 字节的倍数,可能需要添加 3 字节的填充。
这样,总的大小可能是 20 字节(12+5+3,使用压缩指针的情况)或 24 字节(16+5+3,未使用压缩指针)。
Java 示例:简单的 Person 类
考虑下面的 Java 类 Person,它有两个字段:name 和 age。此示例将帮助我们理解类型指针在不涉及继承的情况下如何工作。
public class Person {
private String name;
private int age;
public Person(String name, int age) {
this.name = name;
this.age = age;
}
public void printInfo() {
System.out.println("Name: " + name + ", Age: " + age);
}
public static void main(String[] args) {
Person person = new Person("John Doe", 30);
person.printInfo(); // 输出: Name: John Doe, Age: 30
}
}类型指针的作用
当我们创建 Person 类的一个实例时:
对象创建:JVM 在堆内存中为
Person对象分配空间。这块空间包括对象头、实例数据(name 和 age),以及可能的一些填充字节来满足内存对齐要求。对象头:对象头包含了几个重要的组成部分,其中关键的部分是类型指针。
类型指针:这个指针指向
Person类的元数据。该元数据包含了有关Person类的各种信息,如字段布局、方法表、类加载器信息等。使用类型指针:
当调用
person.printInfo()方法时,即使这个例子没有显示继承和多态,类型指针仍然起到关键作用。JVM 使用类型指针查找到Person类的元数据,进而定位到printInfo()方法的实际代码。这个查找过程确保了即使在类的代码在内存中移动或通过热部署改变时,方法调用也总是正确的,因为类型指针总是指向当前有效的类定义。
(2)、markword 里面具体存的是什么东西

3、synchronized早期为什么叫做重量级锁
在JDK早期 synchronized被称之为重量级锁,是因为我的JVM也是在用户态,有线程向jvm申请锁的时候,那我们JVM需要向操作系统申请锁,那这个过程涉及到从用户态切换到内核态,再从内核态切换到用户态,所以称synchronized为重量级锁。
比较底层的实现(操作系统层面)
当一个 Java 线程尝试进入一个由 synchronized 关键字保护的同步块或方法时,它首先需要获得锁。如果锁已被另一个线程持有,尝试获取锁的线程需要等待。在 Java 的早期实现中,这种等待是通过操作系统的互斥量(Mutex)实现的。这意味着以下几个步骤:
- 锁请求:当线程无法立即获取锁时,JVM 会在用户态下发出一个系统调用,请求操作系统的互斥量。
- 状态切换:执行系统调用(如请求互斥量)涉及从用户态切换到内核态,因为互斥量的管理是在操作系统内核中处理的。
- 等待和唤醒:线程在内核控制的等待队列中阻塞,直到锁变为可用。当锁释放时,操作系统负责唤醒等待的线程,这通常再次涉及从内核态回到用户态的切换。
4、锁升级过程
随着 Java 平台的发展,特别是从 JDK 6 开始,对 synchronized 锁的实现进行了优化。引入了如轻量级锁和偏向锁等技术,使得许多锁操作现在可以完全在用户态下完成,避免了频繁的模式切换。当锁竞争不激烈时,这些优化可以显著减少锁的开销。
在Java中,synchronized关键字实现的锁机制涉及一个从无锁状态到偏向锁、轻量级锁和最终可能的重量级锁的升级过程。这个机制极大地提高了锁的效率,尤其是在不同的并发场景中。下面,我们将更详细地讨论每个锁状态的内部工作机制和对象头中的标记位。
无锁状态
- 锁状态: 当没有线程尝试获取对象的锁时,对象处于无锁状态。无锁并不意味着对象不能被锁定,而是表明当前没有锁定行为。
- 标记位: 在无锁状态下,对象头的标记位通常设置为01(在开启偏向锁的情况下),这表示这个对象可以进入偏向锁状态。
偏向锁
偏向锁是为了优化单个线程多次获取同一个锁的情况。当第一次有线程获取锁时,JVM会将对象头标记为偏向这个线程,以后该线程进入和退出同步块时不需要进行全面的同步。
- 锁状态: 对象头偏向第一个获得它的线程。
- 标记位: 对象头的标记位设置为偏向锁模式,其中包括偏向线程的ID。状态位是01,偏向标志位设置为1,后面跟着偏向的线程ID。
轻量级锁
当另一个线程尝试获取已经偏向某个线程的锁时,偏向锁会升级为轻量级锁。轻量级锁的工作原理是在每个线程的栈帧中创建一个锁记录(Lock Record),它包含了锁对象指针。
- 锁状态: 当锁对象已被线程锁定,但没有其他线程争用它时,使用轻量级锁。
- 标记位: 轻量级锁的状态位为00,锁指针指向线程栈上的锁记录。
重量级锁
当轻量级锁的自旋锁尝试失败,即有多个线程竞争同一个锁时,轻量级锁会升级为重量级锁。这时,JVM会在操作系统层面挂起其他试图获取这个锁的线程。
- 锁状态: 涉及操作系统帮助管理线程阻塞和唤醒的重量级锁。
- 标记位: 重量级锁的状态位通常为10,对象头的锁指针指向一个监视器对象(Monitor),该对象包含了锁的所有者、等待锁的线程列表等信息。
补充内容:
在Java中,轻量级锁升级到重量级锁的自旋次数并没有一个固定的标准,因为这个值可以根据JVM的具体实现和运行时的调优参数而变化。不过,一般来说,HotSpot JVM中有几个因素决定了自旋的次数:
- 自旋次数的默认设置:在早期的HotSpot JVM版本中,自旋的默认次数大约是10次。这意味着如果一个线程尝试获取锁但失败,并且通过自旋的方式进行了约10次重试仍旧无法成功,那么锁将可能被升级为重量级锁。
- 自适应自旋锁:从Java 6开始,HotSpot JVM引入了自适应自旋锁。在这种机制下,自旋的次数不是固定的,而是由JVM在运行时根据之前在同一锁上的自旋行为和锁的竞争情况动态调整的。如果在某个锁上自旋成功率高,JVM可能会增加自旋的次数;反之,如果成功率低,则减少自旋次数。
- JVM调优参数:在HotSpot JVM中,可以通过一些启动参数来调整自旋的行为,例如
-XX:PreBlockSpin参数在某些版本的JVM中用于设置线程在阻塞前进行自旋的次数。
synchronized在java代码层面表示
package com.javatiaozao;
public class Main {
public static void main(String[] args) {
Object o = new Object();
synchronized (o) {
System.out.println("我已经上锁了");
}
System.out.println("Hello world!");
}
}synchronized在JVM汇编层面表示
0 new #2 <java/lang/Object>
3 dup
4 invokespecial #1 <java/lang/Object.<init> : ()V>
7 astore_1
8 aload_1
9 dup
10 astore_2
11 monitorenter
12 getstatic #3 <java/lang/System.out : Ljava/io/PrintStream;>
15 ldc #4 <我已经上锁了>
17 invokevirtual #5 <java/io/PrintStream.println : (Ljava/lang/String;)V>
20 aload_2
21 monitorexit
22 goto 30 (+8)
25 astore_3
26 aload_2
27 monitorexit
28 aload_3
29 athrow
30 getstatic #3 <java/lang/System.out : Ljava/io/PrintStream;>
33 ldc #6 <Hello world!>
35 invokevirtual #5 <java/io/PrintStream.println : (Ljava/lang/String;)V>
38 return并发多线程
回答提示: 是一种比较通俗的解释,比较好理解,如果‘参考回答’不太好记住,可以直接复习回答提示也可以。
1、什么是进程、什么是线程
参考回答: 进程:进程是操作系统分配资源的基本单位。每个进程都有独立的内存空间和系统资源,相互之间互不干扰。比如,运行一个Java程序就启动了一个进程。
线程:线程是进程内的一条执行路径,也是CPU调度的最小单位。一个进程可以包含多个线程,这些线程共享进程的内存和资源。
协程:协程,又称微线程,是一种比线程更加轻量级的存在。协程不是由操作系统内核管理,而是完全由程序控制。协程在单线程条件下实现并发的效果,通过切换执行不同的任务,但实际上是串行执行的。协程的好处在于无需线程上下文切换的开销,也无需原子操作锁定及同步的开销。
2、线程有哪几种状态,状态之间的流转是怎样的?(了解)
新建状态(New):当创建一个新的线程对象时,它处于新建状态,此时还没有开始执行。
就绪状态(Runnable):调用线程的start()方法后,线程进入就绪状态,等待CPU调度执行。
运行状态(Running):线程获得CPU资源后开始执行run()方法,进入运行状态。
阻塞状态(Blocked):当线程由于某种原因(如等待I/O操作完成、等待获取锁、调用sleep()方法等)暂时不能继续执行时,会进入阻塞状态。阻塞状态解除后,线程会重新进入就绪状态,等待CPU调度。
死亡状态(Terminated):线程执行完毕或者因异常退出后,进入死亡状态,此时线程的生命周期结束,不能再被调度执行。
3、创建线程有几种方式
回答提示: 继承Thread类:定义一个Thread类的子类,重写run()方法来实现线程的逻辑。之后创建该子类的实例,并调用start()方法来启动线程。
实现Runnable接口:定义一个实现Runnable接口的类,并实现run()方法。然后创建该类的实例,并以此实例作为目标对象创建一个Thread对象,最后调用Thread对象的start()方法来启动线程。
使用Callable:Callable接口与Runnable类似,但其call()方法可以返回结果并抛出异常。通常将Callable对象传递给FutureTask,再由FutureTask作为Thread对象的目标来启动线程,并可以通过Future对象获取线程执行的结果。
使用线程池:利用Executor框架创建一个线程池之后,将Runnable或Callable对象提交给线程池来执行。
参考答案: 1. 继承Thread类
这是一种直观且简单的创建线程的方法。你可以通过继承Thread类并覆盖其run()方法来定义线程应执行的操作。这种方法简单易懂,但缺点是Java不支持多重继承,如果你的类已经继承了另一个类,就不能再继承Thread类。
public class MyThread extends Thread {
@Override
public void run() {
System.out.println("Thread running");
}
public static void main(String[] args) {
MyThread thread = new MyThread();
thread.start();
}
}2. 实现Runnable接口
实现Runnable接口是另一种常用的方式。你需要实现Runnable接口的run()方法。然后,你可以创建Thread对象并将Runnable实例传递给它。这种方式比继承Thread类更灵活,因为它允许你的类继承其他类。
public class MyRunnable implements Runnable {
@Override
public void run() {
System.out.println("Runnable running");
}
public static void main(String[] args) {
Thread thread = new Thread(new MyRunnable());
thread.start();
}
}3. 使用Callable接口
Callable接口是创建可返回结果的线程的另一种方式。与Runnable不同,Callable允许在任务完成时返回一个值,并且可以抛出检查异常。你需要实现Callable接口的call()方法,该方法将返回一个结果类型。通常,Callable任务在一个线程池内执行,并通过ExecutorService来管理。
import java.util.concurrent.Callable;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
public class MyCallable implements Callable<String> {
@Override
public String call() throws Exception {
return "Result from Callable";
}
public static void main(String[] args) {
ExecutorService executor = Executors.newSingleThreadExecutor();
Callable<String> callable = new MyCallable();
Future<String> future = executor.submit(callable); // 提交Callable任务到线程池
try {
String result = future.get(); // 获取结果
System.out.println("Callable returned: " + result);
} catch (Exception e) {
e.printStackTrace();
} finally {
executor.shutdown();
}
}
}在这个例子中,Callable通过ExecutorService的submit()方法被提交。这种方式不仅支持结果返回,还包括异常处理和更加灵活的线程池管理,这使得它比单独使用Thread类或实现Runnable接口的方法更适合处理复杂的并发任务。
4. 使用线程池(ExecutorService)
虽然不是直接创建线程的方法,使用线程池是一种高效管理线程的方式,尤其适用于需要创建大量线程的情况。通过Executors工厂方法可以创建不同类型的线程池,如newFixedThreadPool。线程池管理所有线程的生命周期,减少了创建和销毁线程的开销。
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class ThreadPoolExample {
public static void main(String[] args) {
ExecutorService executor = Executors.newFixedThreadPool(2);
executor.submit(() -> System.out.println("Task 1 running"));
executor.submit(() -> System.out.println("Task 2 running"));
executor.shutdown();
}
}4、并发的三大特性
回答提示: 原子性:指一个操作或者多个操作要么全部完成,要么全部不完成,不会被中途打断。例如,对一个共享变量进行增加操作,使用synchronized可以防止多个线程同时修改这个变量。
可见性:指当一个线程修改了共享变量的值,其他线程能够立即看到这个修改。例如,一个线程修改了一个标志位,表示数据已准备好,其他等待该数据的线程必须能立即看到这个变化,以便继续执行。
有序性:指程序执行的顺序按照代码的先后顺序执行。例如,在多线程环境下,一个线程先写入数据,然后再读取,这个顺序不能被打乱。如果因为指令重排导致读取操作先执行,就可能会读取到错误的数据。
参考回答: 1. 原子性(Atomicity)
简介: 原子性确保当你执行一个操作时,这个操作要么完全完成,要么完全不发生,中间没有任何状态。在多线程环境中,这可以防止数据在被一个线程修改时被另一个线程读取。
在面试中可以列举如下例子: 在Java中,最常见的实现原子性的方法是使用synchronized关键字,它可以锁定一个对象,确保同一时刻只有一个线程能执行这个代码块。例如,对一个共享变量进行增加操作,使用synchronized可以防止多个线程同时修改这个变量。
public synchronized void increment() {
this.count++;
}2. 可见性(Visibility)
简介: 可见性确保一个线程对共享变量的修改,能被其他线程立即看到,这样就避免了线程之间看到“过期”的数据。
在面试中可以列举如下例子: 使用volatile关键字是实现变量修改可见性的一种简单方式。当一个变量被声明为volatile后,对这个变量的改变会立即更新到主内存中,当其他线程需要读取这个变量时,它们会直接从主内存中读取。
private volatile boolean flag = false;
public void setFlag(boolean flag) {
this.flag = flag; // 对所有线程立即可见
}3. 有序性(Ordering)
简介: 有序性确保程序执行的顺序按照代码书写的顺序执行,防止编译器或处理器为了优化性能而进行的重排序。
在面试中可以列举如下例子: 再次使用volatile关键字可以保证一定的“happens-before”关系,即写入volatile变量的操作之前的所有操作,在读取该变量后都会看到。这可以用来防止创建对象和初始化对象这两个操作的重排序。
private volatile Singleton instance = null;
public Singleton getInstance() {
if (instance == null) { // First check (no locking)
synchronized (this) {
if (instance == null) { // Second check (locking)
instance = new Singleton();
}
}
}
return instance;
}5、什么是CAS
回答提示: CAS(Compare And Swap/Set)比较并交换,CAS 算法的过程是这样:它包含 3 个参数CAS(V,E,N)。V 表示要更新的变量(内存值),E 表示预期值(旧的),N 表示新值。当且仅当 V 值等于 E 值时,才会将 V 的值设为 N,如果 V 值和 E 值不同,则说明已经有其他线程做了更新,则当前线程什么都不做。最后,CAS 返回当前 V 的真实值。
在多线程编程中,CAS(Compare-And-Swap)的概念是一种用于实现同步的原子操作,目的是管理对共享数据的并发访问,特别适用于无锁编程模式。CAS 操作是一种系统级的原子指令,直接由现代处理器硬件支持,因此它比传统的锁机制通常能提供更好的性能和可扩展性。
ABA问题
CAS 会导致“ABA 问题”。CAS 算法实现一个重要前提需要取出内存中某时刻的数据,而在下时刻比较并替换,那么在这个时间差类会导致数据的变化。
比如说一个线程 one 从内存位置 V 中取出 A,这时候另一个线程 two 也从内存中取出 A,并且two 进行了一些操作变成了 B,然后 two 又将 V 位置的数据变成 A,这时候线程 one 进行 CAS 操作发现内存中仍然是 A,然后 one 操作成功。尽管线程 one 的 CAS 操作成功,但是不代表这个过程就是没有问题的。
部分乐观锁的实现是通过版本号(version)的方式来解决 ABA 问题,乐观锁每次在执行数据的修改操作时,都会带上一个版本号,一旦版本号和数据的版本号一致就可以执行修改操作并对版本号执行+1 操作,否则就执行失败。因为每次操作的版本号都会随之增加,所以不会出现 ABA 问题,因为版本号只会增加不会减少。
参考回答: AS 的工作原理
CAS 操作包括三个主要的部分:
内存位置 V:这是需要更新的共享变量的内存地址。
预期原值 E:在执行更新之前,你期望这个内存地址中存储的值。
新值 N:如果当前内存位置的值与预期值匹配,你希望将其更新为的新值。
CAS 操作步骤
读取当前值:系统首先从指定的内存位置 V 读取当前值。
比较当前值与预期值:检查这个当前值是否与预期值 E 相等。
条件性交换:只有当当前值与预期值相等时,系统才将内存位置的值更新为新值 N。
返回结果:操作返回一个布尔值,表示操作是否成功。如果值被成功更新,返回 true;如果当前值不匹配,返回 false,表示没有修改内存位置的值。
具体例子:
读取操作:线程1从共享内存地址V读取值(设为4)。这个值被线程1读到其本地缓存或寄存器中。
本地计算:线程1在自己的本地环境(可能是寄存器或缓存)中,根据某种逻辑或计算,决定将值更新为6。
再次读取并比较:在执行实际的更新之前,线程1再次检查内存地址V的当前值。这个步骤是关键的,它确保在这个线程做出更改决定之后,内存地址V的值没有被其他线程更改。在 CAS 操作中,这部分通常是原子操作的一部分,即“比较并交换”。
条件更新:如果内存地址V的值仍然是4(与线程1之前读取的值相匹配),则线程1会原子性地将该值更新为6。如果值不再是4(说明其他线程已经修改了该值),CAS 操作会失败,不会执行更新。
ABA问题:
CAS(Compare-And-Swap)的 ABA 问题是多线程编程中使用 CAS 操作时可能遇到的一个问题。它发生在一个线程看到一个值从 A 变成 B,然后又变回 A 的时候。当这个线程使用 CAS 进行比较并交换操作时,它只能检测到值是否等于它的预期值(在这种情况下是 A),而无法知道在此过程中该值是否被其他线程修改过。
具体应用场景如下:
假设线程1需要对某个共享变量进行更新,该变量最初的值为 A。线程1从这个变量读取值 A,并计划在完成某些操作后将其更新为另一个值,比如 C。然而,在线程1完成这些操作之前,另一个线程2也访问了同一个变量,它将变量的值从 A 改为 B,然后再改回 A。
当线程1继续执行 CAS 操作以将值从 A 改为 C 时,CAS 操作会成功,因为它检测到变量的当前值仍然是 A,它的预期值。但是,线程1并没有意识到在此期间变量的值已经发生了变化,即使最终的值看起来与开始时相同。这就可能导致基于变量原始值做出的逻辑决策出现错误,因为实际上这个变量的状态可能已经被其他线程以不可见的方式改变了。
6、说说synchronized
回答提示: synchronized 它可以把任意一个非 NULL 的对象当作锁。他属于独占式的悲观锁,同时属于可重入锁。
Synchronized 作用范围
作用于方法时,锁住的是对象的实例(this);
当作用于静态方法时,锁住的是Class实例,又因为Class的相关数据存储在永久带PermGen(jdk1.8 则是 metaspace),永久带是全局共享的,因此静态方法锁相当于类的一个全局锁,会锁所有调用该方法的线程;
synchronized 作用于一个对象实例时,锁住的是所有以该对象为锁的代码块。它有多个队列, 当多个线程一起访问某个对象监视器的时候,对象监视器会将这些线程存储在不同的容器中。
7、说说volatile
回答提示: Java 语言提供了一种稍弱的同步机制,即 volatile 变量,用来确保将变量的更新操作通知到其他线程。
变量可见性:其一是保证该变量对所有线程可见,这里的可见性指的是当一个线程修改了变量的值,那么新的值对于其他线程是可以立即获取的。
禁止重排序:volatile 禁止了指令重排
volatile 适合这种场景:一个变量被多个线程共享,线程直接给这个变量赋值。
参考回答: 1、volatile关键字的作用
(1).可见性(Visibility): 当一个变量被声明为volatile后,它可以确保修改的值立即被更新到主存中,同时每次使用前都从主存中刷新。因此,当一个线程修改了一个volatile变量的值,其他线程可以立即看到这个更新的值。这有助于避免在多线程环境中的可见性问题,保证所有线程看到的变量值都是最新的。
(2).防止指令重排序(Prevention of Instruction Reordering): 在Java内存模型中,编译器和处理器可能会对指令进行重排序,以提高执行效率。但是,这种重排序可能会导致多线程环境中出现意料之外的行为。使用volatile变量时,JVM会插入必要的内存屏障,防止在volatile变量读写操作前后的代码进行重排序,从而保证程序的执行顺序与代码顺序相一致。
2、volatile 关键字的本质是什么
(1)、Jvm内存级别的内存屏障
LoadLoad屏障:
对于这样的语句Load1; LoadLoad; Load2,在Load2及后续读取操作要读取的数据被访问前,保证Load1要读取的数据被读取完毕。
StoreStore屏障:
对于这样的语句Store1:StoreStore; Store2,在Store2及后续写入操作执行前,保证Store1的写入操作对其它处理器可见。
LoadStore屏障:
对于这样的语句Load1: LoadStore; Store2,在Store2及后续写入操作被刷出前,保证Load1要读取的数据被读取完毕
StoreLoad屏障:
对于这样的语句Store1:StoreLoad; Load2,在Load2及后续所有读取操作执行前,保证Store1的写入对所有处理器可见
(2)、volatile 在JVM中怎么做出的规定
---StoreStoreBarrier---
volatile 写
---StoreLoadBarrier---
volatile 读
---LoadLoadBarrier--
---LoadStoreBarrier--8、说说ReentrantLock
回答提示: ReentrantLock是Java中一种可重入、可中断、可公平/非公平的锁。相比synchronized,它提供了更多的控制选项和灵活性。
可重入:同一个线程可以多次获取同一把锁,而不会产生死锁。
可中断:与synchronized不同,ReentrantLock提供了lockInterruptibly()方法,允许在等待锁时被中断。
公平/非公平锁:ReentrantLock允许在创建时选择公平锁或非公平锁。公平锁按照线程请求的顺序获取锁,而非公平锁则不保证顺序。
条件变量:ReentrantLock提供了Condition对象,用于实现更灵活的线程同步控制,比如等待/通知机制。
性能:在多线程竞争激烈或需要条件等待的场景下,ReentrantLock通常比synchronized更高效。
参考回答: 基本概念
ReentrantLock是java.util.concurrent包中提供的一个高级工具,用于同步控制。它提供了与synchronized关键字类似的互斥锁功能,但比synchronized更灵活、更强大。ReentrantLock是可重入的,意味着同一个线程可以多次获得同一个锁,而不会发生死锁。
主要特性
可重入性:ReentrantLock允许同一个线程多次锁定,没有最终释放锁的限制。每次锁定都要有相应的解锁操作,锁的最终释放需要解锁次数与加锁次数相同。
公平性选择:ReentrantLock可以配置为公平锁或非公平锁。公平锁意味着锁的分配会按照线程等待的顺序进行,而非公平锁可能允许闯入,这使得新请求的线程有可能在等待队列中的线程之前获得锁。
提供额外的功能:当需要用到定时锁等待(tryLock(long timeout, TimeUnit unit))或者中断等待锁的线程的功能时,ReentrantLock是一个很好的选择。
替代 synchronized:在高竞争环境下,ReentrantLock 的性能可能优于 synchronized。尤其是使用非公平锁时,它允许线程“插队”,减少上下文切换的开销,从而在高并发场景中表现更优。
lock.lockInterruptibly() 是 ReentrantLock 类中一个非常重要的方法,它使得线程在尝试获取锁的过程中能够响应中断。这个方法的核心特性是它允许线程在等待获取锁时被中断,并适当地处理这种中断。相对于常规的 lock() 方法,lock.lockInterruptibly() 在线程被中断时不会继续等待锁,而是会抛出一个 InterruptedException 并立即释放锁的等待状态。
9、ReentrantLock 与 synchronized区别是什么?
回答提示: 相同点:两者都是可重入锁
两者都是可重入锁。“可重入锁”概念是:自己可以再次获取自己的内部锁。比如一个线程获得了某个对象的锁,此时这个对象锁还没有释放,当其再次想要获取这个对象的锁的时候还是可以获取的,如果不可锁重入的话,就会造成死锁。同一个线程每次获取锁,锁的计数器都自增1,所以要等到锁的计数器下降为0时才能释放锁。
主要区别如下:
ReentrantLock 使用起来比较灵活,但是必须有释放锁的配合动作;
ReentrantLock 必须手动获取与释放锁,而 synchronized 不需要手动释放和开启锁;
ReentrantLock 只适用于代码块锁,而 synchronized 可以修饰类、方法、变量等。
二者的锁机制其实也是不一样的。ReentrantLock 底层调用的是 Unsafe 的park 方法加锁,synchronized 操作的应该是对象头中 mark word
参考回答: 锁的获取与释放:
synchronized:是隐式的。当线程进入同步代码块或方法时,它会自动获取锁,并在退出时自动释放锁。
ReentrantLock:是显式的。需要手动调用lock()方法来获取锁,并调用unlock()方法来释放锁。
锁的公平性:
synchronized:是非公平的,即等待时间最长的线程不一定能最先获得锁。
ReentrantLock:可以是公平的,也可以是非公平的。当设置为公平时,等待时间最长的线程将最先获得锁。
可重入性:
synchronized和ReentrantLock都是可重入的,即一个线程可以多次获取同一个锁。
可中断性:
synchronized:默认情况下,等待锁的线程不会响应中断操作,会继续等待。
ReentrantLock:提供了lockInterruptibly()方法,允许等待锁的线程响应中断操作。
锁的状态查询:
synchronized:不提供查询锁是否被其他线程持有的功能。
ReentrantLock:提供了isLocked()等方法,可以查询锁的状态。
等待/通知机制:
synchronized:使用wait()、notify()和notifyAll()方法进行线程间的等待和通知。
ReentrantLock:与Condition接口结合使用,可以实现更灵活的线程等待/通知机制。
性能:
在某些情况下,ReentrantLock的性能可能优于synchronized,因为它提供了更多的灵活性和更细粒度的控制。然而,这也增加了编程的复杂性。
ReentrantLock基于AQS(AbstractQueuedSynchronizer)实现,AQS内部通过volatile的state读写、CAS操作和在某些条件下让线程进入阻塞状态来实现锁的功能。
10、什么是AQS(了解)
回答提示: AQS,即AbstractQueuedSynchronizer,是Java并发编程中的一个核心组件。它提供了一个框架来构建锁和其他同步器,如ReentrantLock和ReentrantReadWriteLock等。
核心思想:AQS的核心思想是通过一个FIFO(先进先出)的等待队列来管理多线程对资源的访问,以确保线程安全地获取和释放资源。
使用方式:开发者通过继承AQS并实现其抽象方法,可以创建出具体的同步器。AQS提供了模板方法,简化了同步逻辑的实现。
优点:AQS具有高并发性、可扩展性和灵活性,能够自适应地根据不同的应用场景进行优化,提高了Java多线程模型的管理效率与吞吐量。
AQS是Java中实现各种同步机制的重要基础,它使得开发者能够更轻松地构建出高效且线程安全的同步器。
参考回答: AQS(AbstractQueuedSynchronizer)是一个用于构建锁和其他同步组件的框架。它在java.util.concurrent.locks包中提供,是很多并发工具背后的支撑结构,比如各种锁(ReentrantLock、CountDownLatch、Semaphore等)和其他同步器。
AQS的核心功能
AQS使用一个整型的volatile变量来表示同步状态,并通过内置的FIFO队列来管理那些获取同步状态失败的线程。这个框架允许通过改变同步状态和管理线程队列(阻塞和唤醒线程)的方式,来实现不同的同步机制。
AQS的工作原理
状态管理:AQS使用一个int类型的变量来表示同步状态,通过getState、setState和compareAndSetState等方法来管理这个状态。同步器的实现需要根据自身需求来解释这个状态的意义。
节点和队列:AQS内部使用一个叫做Node的内部类来表示等待获取资源的线程。当线程尝试获取资源失败时,它会被包装成一个Node对象并加入到AQS的等待队列中。队列是一个典型的FIFO队列,保证了公平性。
获取与释放资源:AQS定义了两种资源获取方式:独占和共享。独占方式下,每次只有一个线程能执行,而共享方式则允许多个线程同时执行。子类通过实现AQS提供的tryAcquire、tryRelease、tryAcquireShared和tryReleaseShared等方法来定义资源的获取和释放逻辑。
AQS的使用示例
以ReentrantLock为例,这是一个基于AQS实现的互斥锁:
锁定:当一个线程尝试获取锁时,AQS会尝试更新同步状态(即获取资源)。如果成功,当前线程就获取了锁。如果失败,当前线程会被加入到等待队列中,并进入阻塞状态。
解锁:当线程释放锁时,AQS会更新同步状态,并检查等待队列是否有其他线程正在等待这个锁。如果有,它会唤醒队列中的下一个线程。
AQS的优点
高效且可靠:AQS为开发高效且可靠的自定义同步组件提供了强有力的支持。
可重用性:AQS可以被用来实现各种同步组件,极大地减少了重复代码。
可扩展性:开发者可以基于AQS实现自己的同步机制,只需实现几个方法即可。
11、CountDownLatch
回答提示: CountDownLatch是Java中的一个同步辅助类,用于协调多个线程之间的操作。它允许一个或多个线程等待其他线程完成操作。
比如有一个任务 A,它要等待其他 4 个任务执行完毕之后才能执行,此时就可以利用 CountDownLatch来实现这种功能了。 CountDownLatch维护了一个计数器,在初始化时,可以设定一个初始计数值。每当某个事件发生时,计数器的值就会减1。其他线程可以调用await()方法来等待计数器归零,即等待所有事件都发生完毕。一旦计数器归零,等待的线程就会被释放,可以继续执行后续操作。
参考回答: 基本概念
CountDownLatch是java.util.concurrent一个同步辅助工具,它允许一个或多个线程等待其他线程完成各自的工作后再继续执行。CountDownLatch维护一个计数器,初始化时设定计数器的值,每当一个事件完成时,该计数器的值就减一。当计数器的值减到零时,所有因调用CountDownLatch的await()方法而在等待的线程都将被释放继续执行。
使用CountDownLatch的好处
"CountDownLatch提供了一种强大的机制来同步一个或多个任务,通过这种方式,我们可以安全地完成先决条件任务,再执行依赖于这些任务的后续操作。这种机制是非常高效的,因为它避免了像wait()和notify()那样在使用时可能引入复杂性和错误的风险。
12、CyclicBarrier
回答提示: CyclicBarrier是Java中的一个并发工具类,它可以让一组线程互相等待,直到所有线程都到达某个公共屏障点(barrier point)后再一起继续执行。这就像是一群人在赛跑,但要在某个点等所有人都到齐后才能继续跑。
让线程等待:当线程调用CyclicBarrier的await()方法时,它会被阻塞,直到指定数量的线程都调用了await()方法。
释放等待的线程:当足够数量的线程到达屏障点后,CyclicBarrier会允许它们继续执行。这个过程可以循环进行,这也是“Cyclic”(循环的)一词的由来。
参考回答: 基本概念
CyclicBarrier 是 Java 并发编程中的一个同步辅助类,它允许一组线程相互等待,直到所有线程都达到了一个共同的障碍点(Barrier),然后这些线程才可以继续执行。这个类在内部维护一个计数器,该计数器初始化时被设置为等待的线程数。当一个线程到达障碍点时,它会调用 await() 方法,这个方法会阻塞该线程直到所有参与的线程都已调用了 await() 方法。
使用场景
CyclicBarrier 非常适合处理一组线程必须等待彼此到达某个状态后才能一起继续执行的情况。比如,在多线程模拟或计算任务中,你可能需要所有线程在进行下一步计算前完成数据的准备。或者在进行系统级测试时,模拟高并发环境下的多个服务必须同时启动的情况。另外,它是循环的,意味着障碍被触发后,可以被重置并再次使用,非常适合重复的任务处理。
13、Phaser
回答提示: Phaser是Java中的一个高级同步工具类,用于协调多个线程的执行顺序和阶段。
特点:
动态调整:Phaser允许在运行时动态地增加或减少参与者线程的数量,这使得它非常适合处理动态变化的并发任务。
多阶段支持:与CyclicBarrier只支持单个屏障点不同,Phaser支持多个同步阶段,每个阶段都可以看作是一组线程需要同步执行的任务集合。
灵活性和可重用性:Phaser提供了丰富的API来管理线程的同步和阶段转换,如register()、arrive()、arriveAndAwaitAdvance()等。它还可以被重用来执行多个同步任务,而不需要重新创建。
举例:假设有四个玩家(线程)参与一个在线多人游戏,每个玩家需要完成找到线索、组合线索、打开大门三个阶段的任务才能通关。使用Phaser,每个阶段可以看作是一个屏障点,每个玩家在完成自己当前阶段的任务后会向Phaser报告,然后等待其他玩家完成。一旦所有玩家都完成了当前阶段的任务,Phaser就会通知所有玩家可以进入下一阶段,即组合线索打开大门。
参考回答: Phaser 是 Java 中的一个强大的同步辅助类,提供了比 CyclicBarrier 和 CountDownLatch 更灵活的线程同步机制。它主要用于管理一个或多个线程需要多次到达共同的同步点,即所谓的“phase”,从而协调各阶段的完成。
基本概念
Phaser 为多阶段注册提供支持,允许多个线程分步骤地执行任务,同时在每个主要的执行点同步。在内部,Phaser 维护了一个阶段数(phase number)和参与者数量。线程在每个阶段完成后调用 arriveAndAwaitAdvance() 或 arrive(),表示它已完成当前阶段并等待其他线程。当所有注册的参与者都到达时,Phaser 会自动前进到下一个阶段。
使用场景
Phaser 特别适合处理复杂的多阶段任务,其中多个任务或线程必须在每个任务阶段同步。例如,在并行程序中,可以利用 Phaser 进行复杂的数据分析和处理,每个阶段完成特定部分的数据处理后,等待其他部分完成,然后再继续。在模拟应用或游戏开发中,Phaser 可用于控制游戏的多个阶段,确保所有玩家都准备好之后才开始下一阶段。此外,由于其动态的特性,Phaser 也适用于那些在运行时线程数量变化较大的场景。
14、Semaphore
Semaphore(信号量)是Java并发包中提供的另一种同步辅助类,用于控制对有限资源的访问。在多线程环境中,Semaphore 可以限制资源被多个线程同时访问的数量,从而管理并发访问并保持系统的稳定性和效率。
基本概念(了解)
Semaphore 维护了一组许可(permits)。许可的数量由创建 Semaphore 时指定,并且可以通过 release() 和 acquire() 方法动态地增减。这些方法主要用于控制对某一共享资源的访问:
acquire():当一个线程调用 acquire() 方法时,它要求 Semaphore 提供一个许可。如果当前没有可用的许可,该线程将阻塞直到有许可可用或者线程被中断。
release():当任务完成时,线程调用 release() 方法将许可返回给 Semaphore,从而使其他等待许可的线程能够获取许可继续执行。
使用场景
资源池限制:Semaphore 非常适合用于资源池,如数据库连接池、线程池等,其中资源的总数量是固定的。通过 Semaphore 可以控制同时访问资源的线程数量,避免资源过度使用。
服务限流:在Web应用中,Semaphore 可以限制某些服务的并发访问量,以保护系统免受突然的高流量冲击。
并发控制:在复杂的算法中,当需要控制同时运行的任务数量时,可以使用 Semaphore 进行有效管理。
15、ThreadLocal
回答提示: ThreadLocal在Java中是一个用于保存线程本地变量的类。它提供了一种将变量与线程绑定的机制,确保每个线程都拥有自己独立初始化的变量副本。
线程隔离:ThreadLocal为每个线程提供了一个独立的变量空间,线程之间互不影响。这避免了多线程环境下数据共享可能导致的竞争和同步问题。
数据安全性:由于每个线程操作的是自己的数据副本,因此ThreadLocal能在一定程度上保证线程安全,减少同步措施的开销。
使用场景:ThreadLocal常用于保存线程特有的上下文信息,如用户身份、事务状态等。它简化了多线程编程中数据传递的复杂性,提高了代码的可维护性。
资源回收:使用ThreadLocal时需要注意,在线程池环境下,如果不及时清理ThreadLocal中的数据,可能会导致内存泄漏。因此,使用完毕后应调用remove()方法清除数据。
参考回答: ThreadLocal,很多地方叫做线程本地变量,也有些地方叫做线程本地存储,ThreadLocal 的作用是提供线程内的局部变量,这种变量在线程的生命周期内起作用,减少同一个线程内多个函数或者组件之间一些公共变量的传递的复杂度。
ThreadLocalMap(线程的一个属性)
每个线程中都有一个自己的 ThreadLocalMap 类对象,可以将线程自己的对象保持到其中, 各管各的,线程可以正确的访问到自己的对象。
将一个共用的 ThreadLocal 静态实例作为 key,将不同对象的引用保存到不同线程的ThreadLocalMap 中,然后在线程执行的各处通过这个静态 ThreadLocal 实例的 get()方法取得自己线程保存的那个对象,避免了将这个对象作为参数传递的麻烦。
ThreadLocalMap 其实就是线程里面的一个属性,它在 Thread 类中定义 ThreadLocal.ThreadLocalMap threadLocals = null;
应用场景
用户身份信息存储: 在很多应用中,都需要做登录鉴权,一旦鉴权通过之后,就可以把用户信息存储在ThreadLocal中,这样在后续的所有流程中,需要获取用户信息的,直接取ThreadLocal中获取就行了。非常的方便。
线程安全:ThreadLocal可以用来定义一些需要并发安全处理的成员变量,比如SimpleDateFormat,由于 SimpleDateFormat 不是线程安全的,可以使用 ThreadLocal 为每个线程创建一个独立的 SimpleDateFormat 实例,从而避免线程安全问题。
数据库Session:很多ORM框架,如Hibernate、Mybatis,都是使用ThreadLocal来存储和管理数据库会话的。这样可以确保每个线程都有自己的会话实例,避免了在多线程环境中出现的线程安全问题。
16、ThreadLocal内存泄露原因,如何避免(了解)
内存泄露为程序在申请内存后,无法释放已申请的内存空间,一次内存泄露危害可以忽略,但内存泄露堆积后果很严重,无论多少内存,迟早会被占光,不再会被使用的对象或者变量占用的内存不能被回收,就是内存泄露。
强引用:使用最普遍的引用(new),一个对象具有强引用,不会被垃圾回收器回收。当内存空间不足,Java虚拟机宁愿抛出OutOfMemoryError错误,使程序异常终止,也不回收这种对象。如果想取消强引用和某个对象之间的关联,可以显式地将引用赋值为null,这样可以使JVM在合适的时间就会回收该对象。
弱引用:JVM进行垃圾回收时,无论内存是否充足,都会回收被弱引用关联的对象。在java中,用 java.lang.ref.WeakReference类来表示。可以在缓存中使用弱引用。ThreadLocal的实现原理,每一个Thread维护一个ThreadLocalMap,key为使用弱引用的ThreadLocal实例,value为线程变量的副本。
hreadLocalMap使用ThreadLocal的弱引用作为key,如果一个ThreadLocal不存在外部强引用时,Key(ThreadLocal)势必会被GC回收,这样就会导致ThreadLocalMap中key为null, 而value还存在着强引用,只有thead线程退出以后,value的强引用链条才会断掉,但如果当前线程再迟迟不结束的话,这些key为null的Entry的value就会一直存在一条强引用链(红色链条)
key 使用强引用
当hreadLocalMap的key为强引用回收ThreadLocal时,因为ThreadLocalMap还持有ThreadLocal的强引用,如果没有手动删除,ThreadLocal不会被回收,导致Entry内存泄漏。
key 使用弱引用
当ThreadLocalMap的key为弱引用回收ThreadLocal时,由于ThreadLocalMap持有ThreadLocal的弱引用,即使没有手动删除,ThreadLocal也会被回收。当key为null,在下一次ThreadLocalMap调用set(),get(),remove()方法的时候会被清除value值。
因此,ThreadLocal内存泄漏的根源是:由于ThreadLocalMap的生命周期跟Thread一样长,如果没有手动删除对应key就会导致内存泄漏,而不是因为弱引用。
ThreadLocal正确的使用方法
每次使用完ThreadLocal都调用它的remove()方法清除数据
将ThreadLocal变量定义成private static,这样就一直存在ThreadLocal的强引用,也就能保证任 何时候都能通过ThreadLocal的弱引用访问到Entry的value值,进而清除掉 。
17、为什么用线程池?解释下线程池参数?
回答提示: 一是为了有效管理线程,减少创建和销毁线程的开销,提高系统性能;二是能控制并发线程数量,防止系统资源过度消耗。
自定义线程池时,需要关注几个核心参数:
corePoolSize:核心线程数,即线程池中始终保持的线程数量。
maximumPoolSize:最大线程数,线程池允许创建的最大线程数量。
keepAliveTime:非核心线程空闲时的存活时间,超过这个时间会被销毁。
unit:存活时间的单位,如秒、分钟等。
workQueue:工作队列,用于存放待执行的任务。
threadFactory:线程工厂,用于创建新线程,可以设置线程属性。
handler:拒绝策略,当线程池和队列都满了时,处理新任务的方式。
参考回答: 降低资源消耗;提高线程利用率,降低创建和销毁线程的消耗。
提高响应速度;任务来了,直接有线程可用可执行,而不是先创建线程,再执行。
提高线程的可管理性;线程是稀缺资源,使用线程池可以统一分配调优监控。
参数:
public ThreadPoolExecutor(
int corePoolSize, // 核心线程池大小
int maximumPoolSize, // 最大线程池大小
long keepAliveTime, // 非核心线程闲置超时时长
TimeUnit unit, // 超时单位
BlockingQueue<Runnable> workQueue // 任务队列
) {
this(
corePoolSize,
maximumPoolSize,
keepAliveTime,
unit,
workQueue,
Executors.defaultThreadFactory(), // 线程工厂
defaultHandler // 拒绝策略
);
}corePoolSize 代表核心线程数,也就是正常情况下创建工作的线程数,这些线程创建后并不会消除,而是一种常驻线程
maxinumPoolSize 代表的是最大线程数,它与核心线程数相对应,表示最大允许被创建的线程数,比如当前任务较多,将核心线程数都用完了,还无法满足需求时,此时就会创建新的线程,但是线程池内线程总数不会超过最大线程数 】
keepAliveTime 、 unit 表示超出核心线程数之外的线程的空闲存活时间,也就是核心线程不会消除,但是超出核心线程数的部分线程如果空闲一定的时间则会被消除,我们可以通过setKeepAliveTime 来设置空闲时间
workQueue 用来存放待执行的任务,假设我们现在核心线程都已被使用,还有任务进来则全部放入队列,直到整个队列被放满但任务还再持续进入则会开始创建新的线程
ThreadFactory 实际上是一个线程工厂,用来生产线程执行任务。我们可以选择使用默认的创建工厂,产生的线程都在同一个组内,拥有相同的优先级,且都不是守护线程。当然我们也可以选择自定义线程工厂,一般我们会根据业务来制定不同的线程工厂
Handler 任务拒绝策略,有两种情况,第一种是当我们调用 shutdown 等方法关闭线程池后,这时候即使线程池内部还有没执行完的任务正在执行,但是由于线程池已经关闭,我们再继续想线程池提交任务就会遭到拒绝。另一种情况就是当达到最大线程数,线程池已经没有能力继续处理新提交的任务时,这是也就拒绝
18、线程池的拒绝策略有哪些?
AbortPolicy :直接抛出异常,阻止系统正常运行。
CallerRunsPolicy :只要线程池未关闭,该策略直接在调用者线程中,运行当前被丢弃的 任务。显然这样做不会真的丢弃任务,但是,任务提交线程的性能极有可能会急剧下降。
DiscardOldestPolicy :丢弃最老的一个请求,也就是即将被执行的一个任务,并尝试再次提交当前任务。
DiscardPolicy :该策略默默地丢弃无法处理的任务,不予任何处理。如果允许任务丢失,这是最好的一种方案。
19、线程池参数如何设置
这个问题一般会结合你的项目中去回答的,比如说你的核心线程数,最大线程数是如何设置的,如果针对你项目中去问,可以根据如下公式回答。
如果是CPU密集型应用,则线程池大小设置为N+1
如果是IO密集型应用,则线程池大小设置为2N+1
其中线程池的大小就是核心线程数,N表示的是cup的核数,一般服务器的cup大小是8核,最大线程数是核心线程数的2倍,具体的你可以按照公式1回答,比如说,我们的cup大小是8核,核心线程数是9,最大核心线程数是18。拓展知识:CPU密集型:CPU 密集型程序是指在执行过程中主要依赖 CPU 计算能力的程序,CPU 密集型程序通常会消耗大量的 CPU 资源,其执行时间主要由 CPU 计算能力(复杂的计算任务)决定,过多的线程可能会导致 CPU 资源过度竞争,反而降低程序的性能。所以在选择线程数量时需要避免过多的线程,一般线程数量不宜超过 CPU 核心数。
IO密集型: IO 密集型程序是指在执行过程中主要依赖于 IO(如磁盘、网络)操作的程序,IO 密集型程序需要频繁地进行 IO 操作,如文件读写、网络请求等,其执行时间主要受限于 IO 操作的速度,可以使用较多的线程来充分利用CPU的计算能力,比如设置为CPU核数*2。
20、线程池执行原理知道吗?
当提交一个新任务到线程池时,具体的执行流程如下:
提交任务: 使用 ExecutorService 的 execute() 或 submit() 方法提交任务到线程池。
核心线程判断: 如果当前线程池中线程数量小于 corePoolSize,则创建一个新的线程来执行任务。
任务队列判断: 如果当前线程池中线程数量等于 corePoolSize,则将任务加入任务队列中等待执行。
最大线程判断: 如果任务队列已满,并且当前线程池中线程数量小于 maximumPoolSize,则创建一个新的线程来执行任务。
拒绝策略执行: 如果任务队列已满,并且当前线程池中线程数量已经达到 maximumPoolSize,则执行拒绝策略。
线程复用:线程执行完任务后,并不会立即销毁,而是等待新的任务到来,实现线程的复用。
22、说说你对JMM内存模型的理解?为什么需要JMM?(了解)
回答提示: JMM内存模型的理解
JMM是Java语言中的一个抽象概念,它描述了一组规则或规范,通过这些规则定义了程序中各个变量的访问方式。
JMM主要涉及到主内存和工作内存的概念。主内存是共享内存区域,所有变量都存储在这里,而每个线程都有自己的工作内存,用于存储变量的副本。
JMM确保了原子性、可见性和有序性,这是并发编程中的关键要素。
为什么需要JMM:
并发安全性:JMM提供了在并发环境下对共享数据的一致性和同步操作的原子性保证,防止了数据不一致的问题。
性能优化:JMM允许编译器和处理器进行某些优化,同时确保这些优化不会违反内存一致性,从而平衡了性能和正确性。
跨平台一致性:JMM确保了Java程序在不同的硬件和操作系统平台上的内存行为是一致的,符合Java“一次编写,到处运行”的理念。
参考回答: 随着CPU和内存的发展速度差异的问题,导致CPU的速度远快于内存,所以现在的CPU加入了高速缓存,高速缓存一般可以分为L1、L2、L3三级缓存。基于上面的例子我们知道了这导致了缓存一致性的问题,所以加入了缓存一致性协议,同时导致了内存可见性的问题,而编译器和CPU的重排序导致了原子性和有序性的问题,JMM内存模型正是对多线程操作下的一系列规范约束,因为不可能 让陈雇员的代码去兼容所有的CPU,通过JMM我们才屏蔽了不同硬件和操作系统内存的访问差异, 这样保证了Java程序在不同的平台下达到一致的内存访问效果,同时也是保证在高效并发的时候程序能够正确执行。
23、乐观锁和悲观锁的理解及如何实现,有哪些实现方式?
悲观锁:总是假设最坏的情况,每次去拿数据的时候都认为别人会修改,所以每次在拿数据的时候都会上锁,这样别人想拿这个数据就会阻塞直到它拿到锁。
传统的关系型数据库里边就用到了很多这种锁机制,比如行锁,表锁等,读锁,写锁等,都是在做操作之前先上锁。再比如Java里面的同步原语synchronized关键字的实现也是悲观锁。
乐观锁:顾名思义,就是很乐观,每次去拿数据的时候都认为别人不会修改,所以不会上锁,但是在更新的时候会判断一下在此期间别人有没有去更新这个数据,可以使用版本号等机制。
乐观锁适用于多读的应用类型,这样可以提高吞吐量,像数据库提供的类似于write_condition机制,其实都是提供的乐观锁。
在Java中java.util.concurrent.atomic包下面的原子变量类就是使用了乐观锁的一种实现方式CAS实现的。
乐观锁的实现方式:
1、使用版本标识来确定读到的数据与提交时的数据是否一致。提交后修改版本标识,不一致时可以采取丢弃和再次尝试的策略。
2、java中的Compare and Swap即CAS ,当多个线程尝试使用CAS同时更新同一个变量时,只有其中一个线程能更新变量的值,而其它线程都失败,失败的线程并不会被挂起,而是被告知这次竞争中失败,并可以再次尝试。 CAS 操作中包含三个操作数 —— 需要读写的内存位置(V)、进行比较的预期原值(A)和拟写入的新值(B)。如果内存位置V的值与预期原值A相匹配,那么处理器会自动将该位置值更新为新值B。否则处理器不做任何操作。
CAS缺点:
- ABA问题:比如说一个线程one从内存位置V中取出A,这时候另一个线程two也从内存中取出A,并且two进行了一些操作变成了B,然后two又将V位置的数据变成A,这时候线程one进行CAS操作发现内存中仍然是A,然后one操作成功。尽管线程one的CAS操作成功,但可能存在潜藏的问题。从Java1.5开始JDK的atomic包里提供了一个类AtomicStampedReference来解决ABA问题。
- 循环时间长开销大:对于资源竞争严重(线程冲突严重)的情况,CAS自旋的概率会比较大,从而浪费更多的CPU资源,效率低于synchronized。
- 只能保证一个共享变量的原子操作:当对一个共享变量执行操作时,我们可以使用循环CAS的方式来保证原子操作,但是对多个共享变量操作时,循环CAS就无法保证操作的原子性,这个时候就可以用锁。
