在当今竞争激烈的软件开发领域,尤其是在 Java 编程的求职过程中,Java 并发面试题的重要性不言而喻。
随着业务需求的日益复杂和系统规模的不断扩大,并发编程在实际项目中的应用越来越广泛。能够熟练掌握并发知识,并在面试中对相关问题给出准确、深入的回答,是展现技术实力和解决复杂问题能力的关键。
掌握 Java 并发面试题,不仅能证明您对 Java 语言的精通程度,还能展示您在处理高并发、多线程环境下的编程技巧和思维方式。这对于您获得理想的工作机会,提升职业发展空间具有至关重要的作用。
1、什么是线程安全问题
线程安全问题指的是在多线程环境下,多个线程同时访问和操作共享资源时,可能导致数据不一致、程序逻辑错误或其他不可预测的结果。
例如,假设有一个共享的整数变量 count ,多个线程同时对其进行递增操作。如果没有采取适当的同步措施,可能会出现线程 A 读取了 count 的值,还没来得及进行递增操作,线程 B 也读取了 count 的值,然后线程 A 和线程 B 分别进行递增并写入,最终导致 count 的值增加的次数少于预期。
再比如,一个共享的对象 User ,多个线程同时修改其属性值,可能会导致属性值被混乱地修改,从而破坏了对象的完整性和一致性。
线程安全问题是并发编程中需要重点关注和解决的难题,只有通过合理的同步机制、线程间的协调和正确的资源管理,才能有效地避免这些问题,确保程序在多线程环境下的正确性和稳定性。
来看一个经典的案例,这个案例使用了marscode自动生成:
package com.interview.multithreads;
// 定义一个名为ThreadsSafe的公共类,用于多线程安全的示例
public class ThreadsSafe {
// 声明一个私有变量count,用于存储计数器值,初始值为0
private int count = 0;
// 定义一个私有方法increment,用于增加count值
private void increment() {
count++;
}
// 定义一个公共方法runThreads,用于启动并执行两个线程
public void runThreads() {
// 创建第一个线程,使用lambda表达式定义匿名内部类作为线程执行体
Thread thread1 = new Thread(() -> {
for (int i = 0; i < 10000; i++) {
increment();
}
});
// 创建第二个线程,使用lambda表达式定义匿名内部类作为线程执行体
Thread thread2 = new Thread(() -> {
for (int i = 0; i < 10000; i++) {
increment();
}
});
// 启动第一个线程
thread1.start();
// 启动第二个线程
thread2.start();
try {
// 等待第一个线程结束
thread1.join();
// 等待第二个线程结束
thread2.join();
} catch (InterruptedException e) {
// 捕获InterruptedException异常,如果发生异常,打印堆栈跟踪
e.printStackTrace();
}
// 打印计数器的值
System.out.println("Count: " + count);
}
// 定义main方法作为程序入口点
public static void main(String[] args) {
// 创建ThreadsSafe对象
ThreadsSafe threadsSafe = new ThreadsSafe();
// 调用对象的runThreads方法,启动并执行线程
threadsSafe.runThreads();
}
}
运行结果之后,打印出的count值应该会小于预期值20000:
这段代码中产生线程安全问题的原因在于,对共享变量 count 的递增操作 count++ 不是一个原子操作。
在多线程环境中,count++ 实际上可以分解为以下三个步骤:
-
获取当前
count的值。 -
将获取的值加 1 。
-
将加 1 后的值写回
count。
当多个线程同时执行这个操作时,可能会出现以下情况:
例如,线程 1 获取了 count 的值为 5 ,还没来得及完成加 1 和写回操作,线程 2 也获取了 count 的值为 5 。然后线程 1 完成操作将 count 写回为 6 ,接着线程 2 也完成操作将 count 写回为 6 ,这样就丢失了一次递增操作,导致最终的结果不正确。
这种情况可能会在不同的执行顺序中反复出现,使得 count 的最终值不可预测,无法达到预期的累加效果,从而产生了线程安全问题。
当然由于多线程下各线程执行指令的时机无法确定,也有可能出现如下正确的情况:
2、如何解决线程安全问题?
在 Java 中,常见的解决线程安全问题的方法有以下几种:
-
使用
synchronized关键字:-
可以用于修饰方法,使得在同一时刻只有一个线程能够执行该方法。
-
也可以用于修饰代码块,指定一段代码在同一时刻只能被一个线程执行。
示例:
-
public class SynchronizedExample {
private int count = 0;
public synchronized void increment() {
count++;
}
public void runThreads() {
// 线程操作
}
}
-
使用
Lock接口:-
相比
synchronized更加灵活,可以实现更细粒度的控制。 -
例如
ReentrantLock。
-
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
public class LockExample {
private int count = 0;
private Lock lock = new ReentrantLock();
public void increment() {
lock.lock();
try {
count++;
} finally {
lock.unlock();
}
}
}
-
使用线程安全的类:
-
例如
AtomicInteger用于整数的原子操作。
-
import java.util.concurrent.atomic.AtomicInteger;
public class AtomicExample {
private AtomicInteger count = new AtomicInteger(0);
public void increment() {
count.incrementAndGet();
}
}
-
使用线程局部变量(ThreadLocal) :
- 每个线程都有其独立的变量副本,不会相互干扰。
public class ThreadLocalExample {
private ThreadLocal<Integer> count = ThreadLocal.withInitial(() -> 0);
public void increment() {
count.set(count.get() + 1);
}
}
-
使用并发容器:
- 例如
ConcurrentHashMap替代HashMap等。
- 例如
import java.util.concurrent.ConcurrentHashMap;
public class ConcurrentContainerExample {
private ConcurrentHashMap<String, Integer> map = new ConcurrentHashMap<>();
public void operateMap() {
// 并发操作
}
}
在这个案例中,由于count要在多个线程间共享,同时又不涉及到容器,所以可以使用前三种方法改造代码。
- 使用
synchronized关键字:
// 定义一个私有方法increment,用于增加count值
private synchronized void increment() {
count++;
}
2、 使用 Lock 接口:
// 定义一个公共方法runThreads,用于启动并执行两个线程
public void runThreads() {
// 创建一个可重入锁
Lock lock = new ReentrantLock();
// 创建第一个线程,使用lambda表达式定义匿名内部类作为线程执行体
Thread thread1 = new Thread(() -> {
for (int i = 0; i < 10000; i++) {
lock.lock();
try {
increment();
} finally {
lock.unlock();
}
}
});
// 创建第二个线程,使用lambda表达式定义匿名内部类作为线程执行体
Thread thread2 = new Thread(() -> {
for (int i = 0; i < 10000; i++) {
lock.lock();
try {
increment();
} finally {
lock.unlock();
}
}
});
// 启动第一个线程
thread1.start();
// 启动第二个线程
thread2.start();
try {
// 等待第一个线程结束
thread1.join();
// 等待第二个线程结束
thread2.join();
} catch (InterruptedException e) {
// 捕获InterruptedException异常,如果发生异常,打印堆栈跟踪
e.printStackTrace();
}
// 打印计数器的值
System.out.println("Count: " + count);
}
- 使用线程安全的类:
// 声明一个CAS变量,用于存储计数器值,初始值为0
private AtomicInteger count = new AtomicInteger(0);
// 定义一个私有方法increment,用于增加count值
private void increment() {
count.incrementAndGet();
}
3、synchronized原理到底是什么?
synchronized本质上是由JVM来实现的同步机制,所以在Java代码中是看不到它的实现的,但是面试官又特别喜欢考察你对JVM底层的理解。
synchronized底层有一种锁膨胀机制,根据当前并发度的不同(抢占锁的线程多还是少),底层实现是不同的。
synchronized锁膨胀机制在 Java 5 中引入。 在 Java 早期版本中,synchronized的实现相对简单和直接。但从 Java 5 开始,对synchronized进行了优化,引入了锁膨胀机制。
例如,在多线程竞争不激烈的情况下,synchronized可能表现为偏向锁或者轻量级锁,以提高性能。只有在多线程竞争激烈时,才会逐渐膨胀为重量级锁。
这种优化机制使得 Java 在并发编程中的性能得到了显著提升。
JVM将不同并发度的锁级别分为:
- 无锁
- 偏向锁
- 轻量级锁
- 重量级锁
怎么去记录当前是什么级别的锁呢?
对象头中的标识
比如以这段代码为例
synchronized(obj){
}
obj对象在JVM中保存时,除了存放对象本身之外,还会存放一份对象头,而在对象头中包含一块叫做Mark Word的内容,里面就记录了锁的级别。
比如上图展示了32位虚拟机的对象头,最后3位区分出了不同的锁状态
- 无锁
001 - 偏向锁
101 - 轻量级锁
00 - 重量级锁
10
几种锁的详细描述
- 无锁:在初始时,没有任何线程对其进行锁定操作,这就是无锁的情况。
- 偏向锁:是一种针对只有一个线程访问同步块时的优化锁。它假定在大多数情况下,同步操作不会有竞争,所以会偏向于第一个获得锁的线程。例如,在一个单线程的程序中,多次访问同一个同步块,就可能使用偏向锁来提高性能。
- 轻量级锁:当存在少量线程竞争同步资源时使用。它通过 CAS 操作(Compare and Swap,比较并交换)来尝试获取锁,如果获取成功就使用轻量级锁。比如,在一个有两个线程偶尔竞争同一资源的场景中,可能会使用轻量级锁。
- 重量级锁:当存在大量线程竞争同步资源,或者线程阻塞等待的时间较长时,就会升级为重量级锁。重量级锁会导致线程阻塞和唤醒,开销较大。例如,在一个高并发的环境下,众多线程频繁竞争一个关键资源,就需要使用重量级锁来保证线程安全和数据一致性。
重量级锁会使用到Monitor技术。
简单来说就是初始时无锁状态001,一个线程去抢锁变成101,并记录线程ID,如果还是这个线程去抢(根据线程ID判断),那就停留在偏向锁,如果有其他线程来抢,升级为轻量级锁,每次加锁都要在线程中保存下锁的信息,并且指向这个锁信息。如果大量线程抢锁,就要升级为重量级锁了,因为这种情况下要去单独维护阻塞线程等信息,比较复杂,所以使用了Monitor技术。
什么是重量级锁的Monitor技术?
在JVM中,Monitor其实本质上就是一种复杂的数据结构,每一个加锁的对象会持有一个monitor对象,围绕这个数据结构实现了重量级锁的功能。
数据结构的具体实现如下:
详细数据结构代码如下:
ObjectMonitor() {
_header;
_count ; // 非常重要,表示锁计数器,_count = 0表示还没人加锁,_count > 0 表示加锁的次数 _waiters;
_recursions;
_owner; // 非常重要,指向加锁成功的线程,_owner = null 时候表示没人加锁
_waitset; // wait线程的集合,在synchorized代码块中调用wait()方法的线程会被加入到此集合中沉睡,等待别人叫醒它
_waitsetLock;
_responsiable;
_succ;
_cxq;
_freenext;
_entrylist; // 非常重要,等待队列,加锁失败的线程会被加入到这个等待队列中,等待再次争抢锁 _spinFreq; // 获取锁之前的自旋的次数
_spinclock; // 获取之前每次锁自旋的时间
ownerIsThread;
}
- owner 指向当前加锁成功的线程,当然只有一个
- waitset 保存了wait()等待中的线程
- entrylist也叫entry set,保存了阻塞的线程
如果线程1和线程2去抢锁,线程1抢到了,而线程2没有抢到,就会以如下图所示:
如果线程1执行完之后,monitor就会从entry set获得一个线程,让其抢到锁:
什么是自旋锁?
自旋锁(Spin Lock) 是一种用于多线程同步的锁机制。 自旋锁的主要特点是,当一个线程试图获取一个被占用的自旋锁时,它不会进入阻塞状态,而是在原地“自旋”,也就是不停地循环尝试获取锁,直到获取到锁为止。
在重量级锁中,如果一个线程执行代码时间很短,那么其他抢锁线程会频繁处于阻塞-被唤醒的过程中,消耗大量的CPU
例如,在一个实时性要求很高的系统中,如果线程阻塞和唤醒的开销较大,而且预计锁被占用的时间很短,使用自旋锁可以避免线程切换带来的开销。
以下两个参数是由JVM控制的,用来判断要重试多少次以及自旋多少时间,程序员无需过多关注,JVM会帮你搞定
_spinFreq; // 获取锁之前的自旋的次数
_spinclock; // 获取之前每次锁自旋的时间