Java多线程面试题
零、说说你对JMM内存模型的理解?为什么需要JMM?
Java内存模型(Java Memory Model,简称JMM)是一种规范,定义了在多线程程序中,各个线程之间如何通过主存(Main Memory)进行通信以及如何操作共享数据的方式。它确保了在多线程环境中,对共享数据的访问和修改能够保持一定的有序性和可见性,避免出现因为并发导致的不确定性和错误。
JMM的主要目的是解决由于多线程并发访问共享内存时可能出现的以下问题:
- 可见性问题(Visibility Problem): 当一个线程修改了共享变量的值,其他线程不一定能立即看到这个变化,导致线程之间的数据不一致。
- 有序性问题(Ordering Problem): 多线程操作共享变量时,由于指令重排等优化,不同线程的操作顺序可能与代码的原始顺序不一致,导致结果出乎意料。
- 死锁和竞态条件: 多线程环境下,由于不当的同步操作,可能出现死锁和竞态条件(Race Condition),导致程序陷入不可预测的状态。
JMM通过以下机制来保证多线程程序的正确性:
- 原子性(Atomicity): JMM确保特定的操作具有原子性,即操作要么完全执行,要么不执行,没有中间状态。这通常是通过锁和同步操作来实现的。
- 可见性(Visibility): JMM保证对一个变量的修改会被其他线程立即看到,这需要通过volatile关键字、synchronized关键字以及其他同步工具来实现。
- 有序性(Ordering): JMM规定,不同线程的操作要以一定的顺序进行,不会因为指令重排等优化导致结果不一致。这需要使用volatile、synchronized等来建立 happens-before 关系。
一. 重排序有哪些分类?如何避免?
重排序类型
- 编译器重排序。 对于没有先后依赖关系的语句,编译器可以重新调整语句的执行顺序。
- CPU指令重排序。 在指令级别,让没有依赖关系的多条指令并行。
- CPU内存重排序。 CPU有自己的缓存,指令的执行顺序和写入主内存的顺序不完全一致
为了禁止编译器重排序和 CPU 重排序,在编译器和 CPU 层面都有对应的指令,也就是内存屏障(Memory Barrier)。这也正是JMM和happen-before规则的底层实现原理。
编译器的内存屏障,只是为了告诉编译器不要对指令进行重排序。当编译完成之后,这种内存屏障就消失了,CPU并不会感知到编译器中内存屏障的存在。
而CPU的内存屏障是CPU提供的指令,可以由开发者显示调用。
happen-before规则总结
- 单线程中的每个操作,happen-before于该线程中任意后续操作。(线程内的操作不会被重排序)
- 对volatile变量的写,happen-before于后续对这个变量的读。(这保证了对
volatile
变量的修改在其他线程中可见) - 对synchronized的解锁,happen-before于后续对这个锁的加锁。(这保证了多个线程之间的同步)
- 对final变量的写,happen-before于final域对象的读,happen-before于后续对final变量的读。(对于
final
字段的写操作发生在对象的构造过程中,而对于final
字段的读操作则保证了在其他线程中的可见性。) - 传递性规则,A先于B,B先于C,那么A一定先于C发生
二、如何停止一个正在运行的线程
在 Java 中,停止线程是一个需要谨慎处理的操作,因为突然中断线程可能会导致资源泄露、数据不一致等问题。从 Java 11 开始,一些线程停止方法已被标记为不推荐使用,因为它们可能引发不可预测的结果。以下是一些停止线程的方法:
-
使用标志位: 这是一种较为安全和常用的停止线程的方法。在线程的
run
方法中使用一个标志位(例如,布尔型变量)来指示线程是否应该继续运行。当你希望停止线程时,将标志位设置为false
,线程会在下一个循环迭代中退出。public class MyRunnable implements Runnable { private volatile boolean running = true; public void run() { while (running) { // 线程的工作逻辑 } } public void stop() { running = false; } }
-
使用 Thread.interrupt() 方法:
interrupt()
方法是一种中断线程的方式,它会在线程中设置中断标志位。线程可以周期性地检查中断标志位并作出相应的处理。public class MyRunnable implements Runnable { public void run() { while (!Thread.currentThread().isInterrupted()) { // 线程的工作逻辑 } } } // 在其他线程中调用以下代码来中断线程 threadToStop.interrupt();
需要注意的是,
interrupt()
方法并不会直接停止线程,而是在线程中设置了中断标志位,线程在适当的时候可以根据标志位自行退出。 -
使用 Thread.stop()(不推荐):
stop()
方法可以立即终止一个线程,但这个方法已被标记为不推荐使用,因为它可能会引发线程之间的数据不一致和资源泄露等问题。// 不推荐使用 threadToStop.stop();
-
使用 ExecutorService 和 Future: 如果你使用线程池(
ExecutorService
),可以通过调用shutdown()
或shutdownNow()
方法来停止线程池中的所有线程。还可以通过Future
对象的cancel()
方法来中断正在执行的任务。ExecutorService executorService = Executors.newFixedThreadPool(5); Future<?> future = executorService.submit(() -> { // 线程任务逻辑 }); // 取消任务 future.cancel(true); // 停止线程池 executorService.shutdown();
三、notify()和notifyAll()有什么区别?
notify()
和 notifyAll()
是 Java 中用于多线程通信的方法,它们都是在对象级别上进行调用的,用于唤醒等待中的线程。主要的区别在于唤醒的目标线程不同。
-
notify():
notify()
方法用于唤醒等待在该对象上的一个随机线程。如果有多个线程在调用对象的wait()
方法等待该对象的通知,那么只有一个线程会被唤醒。哪个线程会被唤醒是不确定的,取决于 JVM 的调度策略。synchronized (lock) { lock.notify(); // 唤醒一个等待线程 }
-
notifyAll():
notifyAll()
方法用于唤醒等待在该对象上的所有线程。当有多个线程等待时,调用notifyAll()
方法会唤醒所有等待线程,让它们有机会争夺对象的锁。synchronized (lock) { lock.notifyAll(); // 唤醒所有等待线程 }
通常情况下,推荐使用 notifyAll()
来确保所有等待线程都有机会获取到对象的锁并进行相应的操作。这可以避免因为使用 notify()
而导致某些线程一直没有机会被唤醒,从而造成死锁或饥饿等问题。
需要注意的是,wait()
、notify()
和 notifyAll()
都必须在同步代码块中(使用对象的监视器锁)调用,否则会抛出 IllegalMonitorStateException
异常。此外,等待线程和通知线程之间的竞争条件需要进行适当的同步来确保线程安全。
3.1 使用 notify() 为什么会造成死锁或饥饿等问题
使用 notify()
方法可能会导致死锁或饥饿问题,主要是因为在多线程环境下,对于等待线程的唤醒和竞争锁的获取存在一些潜在的风险,如果不谨慎处理,就会导致这些问题。
- 死锁: 当使用
notify()
唤醒等待线程时,如果被唤醒的线程试图获取某个其他资源的锁,而该资源又正好被其他线程占用,就可能发生死锁。例如,线程 A 等待对象 A 的锁,线程 B 等待对象 B 的锁,线程 A 被唤醒并获取了对象 A 的锁,然后线程 B 被唤醒并获取了对象 B 的锁,这时候两个线程互相等待对方释放资源,造成死锁。 - 饥饿: 如果使用
notify()
来唤醒等待线程,但唤醒的是相同类型的线程,那么可能会导致某些线程始终无法获得执行的机会,从而导致饥饿问题。例如,一个线程 A 被唤醒后,获取了锁并完成了任务,然后线程 B 被唤醒,又获取了锁并完成了任务,依此类推。如果线程 A、B、C 等的数量很大,而每次只能唤醒一个线程,那么某些线程可能会一直无法被唤醒,造成饥饿。
为了避免这些问题,通常建议使用 notifyAll()
方法来唤醒所有等待线程。这样做的好处是,每个等待线程都有机会争夺锁,从而避免了某些线程一直无法被唤醒的情况。但要注意,过度使用 notifyAll()
也可能会引起性能问题,因为会增加竞争和上下文切换的开销。
要安全地使用 notify()
和 notifyAll()
,必须在合适的同步代码块中使用,并确保等待线程和唤醒线程之间的同步关系正确。合理的设计和同步机制可以有效地避免死锁和饥饿问题。
3.2 举个简单易懂的例子说明死锁
以下是一个简单的例子,说明死锁的情况:
假设有两个线程,Thread A 和 Thread B,它们同时访问两个共享资源,Resource 1 和 Resource 2。每个线程都需要同时获取这两个资源才能继续执行。如果 Thread A 先获取了 Resource 1,而 Thread B 同时获取了 Resource 2,那么它们会陷入相互等待的状态,无法继续执行下去,从而导致死锁。
public class DeadlockExample {
private static Object resource1 = new Object();
private static Object resource2 = new Object();
public static void main(String[] args) {
Thread threadA = new Thread(() -> {
synchronized (resource1) {
System.out.println("Thread A: Holding resource 1...");
try {
Thread.sleep(100);
} catch (InterruptedException e) {
}
System.out.println("Thread A: Waiting for resource 2...");
synchronized (resource2) {
System.out.println("Thread A: Holding resource 1 and resource 2...");
}
}
});
Thread threadB = new Thread(() -> {
synchronized (resource2) {
System.out.println("Thread B: Holding resource 2...");
try {
Thread.sleep(100);
} catch (InterruptedException e) {
}
System.out.println("Thread B: Waiting for resource 1...");
synchronized (resource1) {
System.out.println("Thread B: Holding resource 1 and resource 2...");
}
}
});
threadA.start();
threadB.start();
}
}
在这个例子中,Thread A 和 Thread B 分别尝试获取 Resource 1 和 Resource 2 的锁。由于它们获取锁的顺序不同,当 Thread A 获取了 Resource 1 的锁,同时 Thread B 获取了 Resource 2 的锁,它们会相互等待对方释放资源,从而形成了死锁。
为了避免死锁,通常可以通过按照相同的顺序获取锁,使用超时机制来放弃获取锁,或者使用更复杂的同步策略来解决资源竞争问题。
3.3 为什么还要有notify()
notify() 是对notifyAll()的一个优化,但它有很精确的应用场景,并且要求正确使用。不然可能导致死锁。正确的场景应该是 WaitSet中等待的是相同的条件,唤醒任一个都能正确处理接下来的事项,如果唤醒的线程无法正确处理,务必确保继续notify()下一个线程,并且自身需要重新回到WaitSet中.
四、sleep()和wait() 有什么区别?
sleep()
和 wait()
都是 Java 中用于控制线程的方法,但它们的用途和行为有很大的区别。
-
sleep()
方法:sleep()
是Thread
类的一个静态方法,用于让当前线程暂停一段时间(以毫秒为单位)。- 在调用
sleep()
期间,线程仍然持有之前获得的锁,不会释放锁。其他线程无法获得这个锁,即使它们正在等待。 sleep()
方法可能会抛出InterruptedException
,表示在线程休眠期间被中断。
javaCopy code try { Thread.sleep(1000); // 休眠 1 秒 } catch (InterruptedException e) { // 处理中断异常 }
-
wait()
方法:wait()
是Object
类的方法,用于在对象上等待,并且会释放对象的锁。它必须在同步块中调用,即在使用对象的监视器锁(synchronized)的范围内。- 调用
wait()
会使线程进入等待状态,直到其他线程在相同的对象上调用了notify()
或notifyAll()
方法来唤醒等待的线程。 wait()
方法可以接受一个超时参数,如果等待时间超过指定的超时时间,线程会自动唤醒。
javaCopy code synchronized (lock) { try { lock.wait(); // 在 lock 对象上等待 } catch (InterruptedException e) { // 处理中断异常 } }
生活场景举例
想象你正在一家繁忙的餐厅就餐,你点了食物,但由于餐厅很忙,需要一些时间才能准备好你的食物。在这个场景中,你是等待服务的线程,餐厅员工是服务线程,而你的食物是共享的资源。
使用 sleep()
:
在这个场景中,你可能决定等待一段时间,给餐厅足够的时间准备食物,然后再来取食物。
同步等与异步等通知
使用 wait()
和 notify()
:
另一方面,如果餐厅员工已经准备好了你的食物,他们可以通过声音或其他方式通知你。在这个场景中,餐厅员工就像是其他线程通过 notify()
唤醒等待线程,而你则是等待线程,等待食物准备好。
总结:同步等(不释放锁),异步等通知(释放锁)。
五、volatile 是什么?可以保证有序性吗?
volatile
是 Java 中的一个关键字,它主要用于修饰变量,用于确保多线程环境下对变量的访问具有一定的可见性和有序性。虽然 volatile
可以保证可见性,但不能完全保证有序性。
volatile
的特性:
- 可见性(Visibility): 当一个线程修改了一个被
volatile
修饰的变量的值,其他线程可以立即看到这个修改。这是因为volatile
变量的修改会立即刷新到主内存,并且其他线程在读取该变量时会从主内存中获取最新值。 - 有序性(Ordering):
volatile
可以确保变量的读写操作是按照顺序进行的,即禁止指令重排序。然而,它并不能解决所有的有序性问题,因为它仅限于单个变量的操作。在涉及多个变量之间的复合操作时,volatile
无法提供足够的保证。
尽管 volatile
可以保证变量的读写操作按照顺序进行,但volatile 不是原子性操作,它并不能解决所有的并发问题,特别是复合操作和复杂的同步需求。例如,volatile
无法保证一组相关变量之间的一致性,也不能代替锁来实现复杂的线程同步。
如果需要更强大的原子性、有序性和可见性保证,你可能需要使用其他同步工具,如 synchronized
块、Lock
接口、Atomic
类等,以根据具体情况来解决并发问题。
5.1 请举例说明为什么 volatile 无法保证一组相关变量之间的一致性
当涉及一组相关变量的复合操作时,volatile
无法保证这些变量之间的一致性。这是因为 volatile
能确保单个变量的读写操作的可见性和顺序性,但不能保证复合操作的原子性和正确性。
以下是一个例子,说明 volatile
无法保证一组相关变量之间的一致性:
javaCopy code
public class VolatileConsistencyExample {
private volatile int x = 0;
private volatile int y = 0;
public void write() {
x = 1;
y = 2;
}
public void read() {
if (y == 2 && x == 0) {
System.out.println("Inconsistent state detected!");
}
}
public static void main(String[] args) {
VolatileConsistencyExample example = new VolatileConsistencyExample();
Thread writerThread = new Thread(() -> {
example.write();
});
Thread readerThread = new Thread(() -> {
example.read();
});
writerThread.start();
readerThread.start();
}
}
在这个例子中,有两个变量 x
和 y
,分别被 volatile
修饰。write()
方法修改了这两个变量的值,而 read()
方法检查了这两个变量的值。根据预期,如果 x
的值是 1,y
的值是 2,那么就是一个一致的状态。
然而,由于 volatile
无法保证复合操作的原子性,read()
方法可能会检查到 y
的值为 2,但同时 x
的值仍然是 0,尽管在逻辑上应该是一个不一致的状态。这是因为 volatile
仅能保证单个变量的读写操作是按顺序进行的,但无法确保多个变量之间的操作是原子性的。
要解决这类问题,通常需要使用更强大的同步机制,如锁或者 Atomic
类,来确保复合操作的原子性和正确性。
5.2 volatile关键字的作用
volatile
是 Java 中的一个关键字,用于修饰变量。它的作用是告诉编译器和运行时系统,被 volatile
修饰的变量在多线程环境中具有特殊的可见性和内存语义。
volatile
的主要作用是:
- 保证可见性: 被
volatile
修饰的变量在一个线程中被修改后,会立即将最新的值写回主内存,同时其他线程在访问该变量时会从主内存中读取最新的值,保证了变量的可见性。这可以用于确保线程之间能够正确地看到变量的更新。 - 禁止指令重排序:
volatile
关键字会禁止编译器和处理器对被修饰变量的读写操作进行重排序。这样可以防止一些潜在的并发问题,确保读写操作的顺序与程序代码的顺序一致。 - 不保证原子性: 尽管
volatile
提供了可见性和禁止重排序的特性,但它并不保证复合操作的原子性。例如,递增操作count++
虽然是原子性的单个操作,但是在多线程环境下,由于读取、计算、写入操作的分离,可能会导致并发问题。
适用场景:
volatile
适用于多线程环境下一个线程写入变量,其他线程读取变量的情况,用于实现一些状态标志、控制开关等。- 不适用于复合操作的原子性保证,对于复杂的原子操作,需要使用
synchronized
或者java.util.concurrent
包提供的原子类。
volatile和synchronized的对比
volatile
和 synchronized
都是 Java 中用于多线程编程的关键字,但它们有不同的作用和适用场景。以下是它们的对比:
-
可见性和原子性:
volatile
:主要用于保证被修饰变量的可见性。它会禁止线程对变量的访问从寄存器或者缓存中获取,而是直接从主内存读取。volatile
不保证复合操作的原子性。synchronized
:不仅保证变量的可见性,还可以保证一段代码块的原子性。synchronized
会确保在同一时间只有一个线程可以进入同步代码块,从而避免竞态条件。
-
适用范围:
volatile
:适用于一个线程写入变量,多个线程读取变量的情况。常用于状态标志、控制开关等场景,而不适用于复合操作的原子性保证。synchronized
:适用于任何需要控制多个线程对共享资源并发访问的情况。它可以用于保证临界区代码块的原子性和一致性。
-
性能开销:
volatile
:相比synchronized
,volatile
的性能开销较小,因为它不涉及锁的获取和释放,不会造成线程的阻塞。synchronized
:涉及锁的获取和释放,可能会引入较大的性能开销。此外,synchronized
的锁粒度较大,可能导致更多的竞争,可能会造成线程的阻塞。
-
粒度和复杂度:
volatile
:粒度较小,只能修饰变量,简单易用。但它无法保证复合操作的原子性。synchronized
:粒度较大,可以修饰方法、代码块等,提供了更丰富的同步控制。但使用起来较复杂,需要注意死锁和竞态等问题。
综上所述,volatile
主要用于保证变量的可见性,适用于特定的读-写场景。synchronized
不仅保证可见性,还提供了更强大的同步控制,适用于更复杂的多线程同步和共享资源访问场景。在实际使用中,需要根据具体情况选择适合的同步机制。
六、Thread 类中的start() 和 run() 方法有什么区别?
- 使用
start()
方法启动线程会创建一个新的系统线程,并在后台执行run()
方法中的代码,实现多线程的并行处理。 - 直接调用
run()
方法只是普通的方法调用,不会创建新的线程,只是在当前线程中执行,不会实现多线程并行处理。
七、为什么wait, notify 和 notifyAll这些方法不在Thread类里面?
wait()
、notify()
和 notifyAll()
这些方法没有直接放在 Thread
类中,而是放在 Object
类中,是因为它们与线程的等待和通知机制紧密相关,更适合与对象的锁机制一起使用。这种设计使得任何对象都可以作为同步监视器,从而在多线程编程中更加灵活和通用。
主要的原因包括:
- 等待和通知是对象级别的操作: 等待和通知操作是针对对象的,而不是线程。每个对象都有自己的等待队列和通知机制,所以将这些操作放在
Object
类中更为合适。 - 多个线程可以等待同一个对象: 多个线程可以等待同一个对象的状态变化,例如一个共享资源的状态改变。如果这些方法放在
Thread
类中,就会对具体线程造成限制,而将它们放在Object
类中可以方便地用于多线程环境。
八、为什么wait和notify方法要在同步块中调用?
wait()
和 notify()
方法需要在同步块中调用,是因为它们依赖于对象的监视器锁(也称为内置锁或对象锁)来实现线程的等待和唤醒机制。以下是为什么要在同步块中调用这些方法的解释:
- 对象监视器锁: 每个 Java 对象都有一个关联的监视器锁,也就是对象锁。当你使用
synchronized
关键字或者synchronized
方法来同步代码块时,实际上是在获取该对象的监视器锁。 - 线程等待和唤醒:
wait()
方法用于使调用该方法的线程进入等待状态,同时释放获取的对象锁,让其他线程可以获取锁并继续执行。notify()
方法用于唤醒等待在相同对象上的一个随机线程,让它继续执行。notifyAll()
方法用于唤醒等待在相同对象上的所有线程。 - 确保同步: 为了避免线程之间的竞争和数据不一致,这些方法必须在获取对象锁的情况下才能调用。否则,如果在没有获取对象锁的情况下调用这些方法,将会抛出
IllegalMonitorStateException
异常。
因此,要正确使用 wait()
、notify()
和 notifyAll()
方法,必须在同步块内部使用。这样可以确保线程在等待和唤醒过程中正确地获取和释放对象锁,从而实现线程之间的协调和同步。如果在不同的同步块中调用这些方法,可能会导致无法正确地唤醒等待线程或者其他并发问题
九、Java中interrupted 和 isInterruptedd方法的区别?
interrupted()
和 isInterrupted()
都是用于处理线程中断的方法,但它们在使用和行为上有一些区别。
-
interrupted()
方法:interrupted()
是一个静态方法,它是Thread
类的方法,用于检查当前线程的中断状态,并且会将中断状态标志清除(重置为false
)。- 如果当前线程的中断状态为
true
,则interrupted()
方法返回true
,并清除中断状态。否则返回false
。
boolean interruptedStatus = Thread.interrupted(); // 检查并清除中断状态
一般来说,
interrupted()
方法用于检查线程的中断状态,并在适当的时候清除中断状态。如果你希望处理线程的中断,但仍然希望保留中断状态,可以使用isInterrupted()
方法。 -
isInterrupted()
方法:isInterrupted()
是一个实例方法,它是Thread
类的方法,用于检查指定线程的中断状态,但不会修改中断状态。- 如果线程的中断状态为
true
,则isInterrupted()
方法返回true
,否则返回false
。
boolean interruptedStatus = thread.isInterrupted(); // 检查中断状态,但不清除
isInterrupted()
方法通常用于检查线程的中断状态,以便根据中断状态来执行适当的操作,而不会改变中断状态本身。
总结:
interrupted()
是静态方法,会清除当前线程的中断状态,并返回之前的中断状态。isInterrupted()
是实例方法,用于检查线程的中断状态,但不会修改中断状态。- 如果你想在处理中断的同时保留中断状态,可以使用
isInterrupted()
方法。如果你想在检查中断状态后清除中断状态,可以使用interrupted()
方法。
十、Java中synchronized 和 ReentrantLock 有什么不同?
synchronized
和 ReentrantLock
都是 Java 中用于实现线程同步的机制,但它们在用法和功能上有一些不同之处。
1. synchronized
:
synchronized
是 Java 内置的关键字,是原生语法层面的互斥,需要jvm实现。它可以用于方法、代码块或实例方法上,以获得对象级别的锁。synchronized
提供了隐式的锁机制,当一个线程进入同步块时,它会自动获取锁,并在退出同步块时释放锁。- 只有一个线程可以获得对象的锁,其他线程必须等待锁被释放才能进入同步块。
synchronized
除了提供锁的功能外,还具有一些优化,如锁升级和锁消除。
2. ReentrantLock
:
ReentrantLock
是java.util.concurrent.locks
包下的类,是JDK 1.5之后提供的API层面的互斥锁。它提供了显示的锁机制,需要显式地获取和释放锁(需要lock()和unlock()方法配合try/fifinally语句块来完成)。ReentrantLock
提供了更多的灵活性和控制,如可重入性(同一线程可以多次获取同一个锁)、公平锁(按照请求的顺序分配锁)、可定时的等待等。- 可以使用
tryLock()
方法尝试获取锁,避免长时间的等待。 ReentrantLock
提供了条件变量(Condition
)的支持,可以用于实现更复杂的线程等待和唤醒机制。
主要区别:
synchronized
是隐式的,不需要手动释放锁,而ReentrantLock
则需要显式地使用lock()
和unlock()
方法来获取和释放锁。ReentrantLock
提供了更多的灵活性和控制,适用于复杂的同步需求。- 在性能方面,
synchronized
有时会因为锁争用问题而导致性能下降,而ReentrantLock
的性能通常较好。 - 在并发性和可维护性方面,
ReentrantLock
可以提供更多的控制,但使用更复杂,需要谨慎处理避免死锁等问题。
总之,选择使用 synchronized
还是 ReentrantLock
取决于具体的需求和场景,ReentrantLock
提供了更高级的特性,但也需要更多的注意和小心。
十一、有哪些方法可以保证三个线程 T1、T2 、T3 按顺序执行?
以下是几种可能的方案:
1. 使用 join() 方法: 可以在线程之间使用 join()
方法来等待前一个线程执行完成后再执行下一个线程。例如:
javaCopy code
public class SequentialExecutionExample {
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
System.out.println("T1 is executing");
});
Thread t2 = new Thread(() -> {
try {
t1.join(); // 等待 T1 执行完成
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("T2 is executing");
});
Thread t3 = new Thread(() -> {
try {
t2.join(); // 等待 T2 执行完成
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("T3 is executing");
});
t1.start();
t2.start();
t3.start();
}
}
2. 使用 CountDownLatch: CountDownLatch
是一种同步辅助工具,可以用于控制多个线程的执行顺序。每个线程在执行完一部分任务后调用 countDown()
方法,主线程或其他等待线程在等待之前调用 await()
方法等待计数器变为零。例如:
javaCopy code
import java.util.concurrent.CountDownLatch;
public class SequentialExecutionExample {
public static void main(String[] args) throws InterruptedException {
CountDownLatch latch1 = new CountDownLatch(1);
CountDownLatch latch2 = new CountDownLatch(1);
Thread t1 = new Thread(() -> {
System.out.println("T1 is executing");
latch1.countDown();
});
Thread t2 = new Thread(() -> {
try {
latch1.await(); // 等待 T1 执行完成
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("T2 is executing");
latch2.countDown();
});
Thread t3 = new Thread(() -> {
try {
latch2.await(); // 等待 T2 执行完成
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("T3 is executing");
});
t1.start();
t2.start();
t3.start();
}
}
3. 使用信号量(Semaphore): Semaphore
也是一种同步工具,可以用于控制线程的执行顺序。每个线程在执行完一部分任务后调用 release()
方法,后续线程在执行前调用 acquire()
方法等待信号量释放。例如:
javaCopy code
import java.util.concurrent.Semaphore;
public class SequentialExecutionExample {
public static void main(String[] args) throws InterruptedException {
Semaphore semaphore1 = new Semaphore(0);
Semaphore semaphore2 = new Semaphore(0);
Thread t1 = new Thread(() -> {
System.out.println("T1 is executing");
semaphore1.release();
});
Thread t2 = new Thread(() -> {
try {
semaphore1.acquire(); // 等待 T1 执行完成
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("T2 is executing");
semaphore2.release();
});
Thread t3 = new Thread(() -> {
try {
semaphore2.acquire(); // 等待 T2 执行完成
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("T3 is executing");
});
t1.start();
t2.start();
t3.start();
}
}
在 new Semaphore(0)
的情况下,初始许可数为 0,这意味着没有线程可以立即获得许可,所有尝试获得许可的线程都将被阻塞,直到有其他线程释放许可。通常情况下,这样的信号量用于线程等待和通知的场景。
4. 使用 wait()/notifyAll(): 通过合适的等待和唤醒机制,可以保证线程 T1、T2 和 T3 按照顺序执行。
在这个例子中,使用了一个共享的 lock
对象来进行线程同步,以确保线程按照指定的顺序执行。每个线程在执行前都会通过 wait()
方法等待,直到轮到它执行为止,然后执行相应的操作,并更新 currentThread
的值,以唤醒下一个线程。notifyAll()
方法用于唤醒等待的线程。
public class SequentialExecutionExample {
private static final Object lock = new Object();
private static int currentThread = 1;
public static void main(String[] args) {
Thread t1 = new Thread(() -> {
synchronized (lock) {
while (currentThread != 1) {
try {
lock.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
System.out.println("T1 is executing");
currentThread = 2;
lock.notifyAll();
}
});
Thread t2 = new Thread(() -> {
synchronized (lock) {
while (currentThread != 2) {
try {
lock.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
System.out.println("T2 is executing");
currentThread = 3;
lock.notifyAll();
}
});
Thread t3 = new Thread(() -> {
synchronized (lock) {
while (currentThread != 3) {
try {
lock.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
System.out.println("T3 is executing");
}
});
t1.start();
t2.start();
t3.start();
}
}
这些是只是几种可能的方法来保证线程的顺序执行。根据具体需求和场景,你可以选择适合的同步机制来实现线程的有序执行。
十二、怎么控制同一时间只有 3 个线程运行?有哪些实现方法
ChatGPT
要控制同一时间只有 3 个线程运行,有多种实现方法,我将介绍一些常见的方法:
1. 使用 Semaphore(信号量): 信号量是一种经典的同步工具,可以用来控制并发访问资源的数量。你可以创建一个初始值为 3 的信号量,然后在线程中执行任务时,通过 acquire()
方法获取信号量,执行任务完成后通过 release()
方法释放信号量。
javaCopy code
import java.util.concurrent.Semaphore;
public class LimitedConcurrencyExample {
public static void main(String[] args) {
int maxConcurrency = 3;
Semaphore semaphore = new Semaphore(maxConcurrency);
for (int i = 0; i < 10; i++) {
Thread thread = new Thread(() -> {
try {
semaphore.acquire(); // 获取信号量
System.out.println("Thread " + Thread.currentThread().getId() + " is running");
Thread.sleep(1000); // 模拟任务执行
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
semaphore.release(); // 释放信号量
}
});
thread.start();
}
}
}
2. 使用线程池: Java 提供了线程池来管理和控制线程的执行。你可以使用 Executors.newFixedThreadPool(3)
来创建一个固定大小的线程池,只有 3 个线程可以同时执行。
javaCopy code
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class LimitedConcurrencyExample {
public static void main(String[] args) {
int maxConcurrency = 3;
ExecutorService executor = Executors.newFixedThreadPool(maxConcurrency);
for (int i = 0; i < 10; i++) {
executor.execute(() -> {
System.out.println("Thread " + Thread.currentThread().getId() + " is running");
try {
Thread.sleep(1000); // 模拟任务执行
} catch (InterruptedException e) {
e.printStackTrace();
}
});
}
executor.shutdown();
}
}
3. 使用 CountDownLatch: 你可以使用 CountDownLatch
来控制线程的并发执行。每个线程在执行前调用 countDown()
方法,主线程或其他线程在等待前调用 await()
方法等待计数器归零。
javaCopy code
import java.util.concurrent.CountDownLatch;
public class LimitedConcurrencyExample {
public static void main(String[] args) throws InterruptedException {
int maxConcurrency = 3;
CountDownLatch latch = new CountDownLatch(maxConcurrency);
for (int i = 0; i < 10; i++) {
Thread thread = new Thread(() -> {
System.out.println("Thread " + Thread.currentThread().getId() + " is running");
try {
Thread.sleep(1000); // 模拟任务执行
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
latch.countDown(); // 计数器减少
}
});
thread.start();
}
latch.await(); // 等待所有线程执行完毕
}
}
十三、什么是线程安全
如果你的代码在多线程下执行和在单线程下执行永远都能获得一样的结果(即一致性和正确性),那么你的代码就是线程安全的。线程安全的代码可以同时由多个线程调用,而不会引发数据竞争、死锁、数据损坏或其他并发问题。
线程安全可以通过以下方式实现:
- 互斥锁(Mutex): 使用锁机制(如
synchronized
关键字、ReentrantLock
等)来确保同一时间只有一个线程能够访问共享资源。锁会阻止其他线程在同一时间内对资源进行修改。 - 原子操作: 使用原子操作(Atomic Operations)来保证一些基本操作的原子性,使它们不会被中断,从而避免数据竞争。像Vector这种,add、remove方法都是原子操作,不会被打断,但也仅限于此,如果有个线程在遍历某个Vector、有个线程同时在add这Vector,99%的情况下都会出现ConcurrentModifificationException,也就是fail-fast机制。
- 不可变对象: 创建不可变对象(像String、Integer、Long这些,都是fifinal类型的类),使其状态在创建后不能被修改,从而消除了并发修改的问题。
- 线程本地存储: 使用线程本地存储(Thread-Local Storage,TLS)来在每个线程中保持独立的数据副本,避免线程间的干扰。
- 同步容器: 使用线程安全的容器(如
ConcurrentHashMap
、CopyOnWriteArrayList
等)来保证对容器的并发操作是安全的。 - 合理的线程设计: 避免不必要的共享,合理地设计线程之间的交互,减少竞争条件。
实现线程安全需要根据具体情况选择合适的方法。在多线程编程中,保持线程安全是一个关键的目标,以确保程序的正确性和可靠性。
线程安全需要保证几个基本特征?
线程安全是多线程编程中的一个重要概念,它需要保证以下几个基本特征:
- 原子性(Atomicity): 原子性是指操作在执行过程中不会被中断,要么全部执行成功,要么全部不执行。在多线程环境下,原子性确保了复合操作的不可分割性(一般通过同步机制实现),防止竞态条件的发生。
- 可见性(Visibility): 可见性是指一个线程对共享变量的修改对其他线程是可见的。当一个线程修改了共享变量的值,其他线程应该能够立即看到这个变化,以避免脏读、不一致等问题。
- 有序性(Ordering): 有序性是指程序的执行顺序与代码的顺序一致。在多线程环境中,由于指令重排序的存在,可能会导致操作的执行顺序与程序代码的顺序不一致,从而引发问题。
- 互斥性(Mutual Exclusion): 互斥性是指多个线程不能同时进入临界区(同步代码块或方法),防止多个线程对共享资源的并发访问引发竞态条件。
这些基本特征的保证需要使用适当的同步机制,如 synchronized
、volatile
、并发集合类等,来确保多线程程序在共享资源访问时能够正确地实现线程安全。同时,也需要理解并考虑线程的执行顺序、指令重排序等因素,以避免潜在的并发问题。
fail-fast机制是什么
"Fail-fast" 机制是一种用于检测错误并尽早失败的设计原则,通常在软件开发中用来处理数据结构的访问和操作。它的核心思想是,一旦发现错误或不一致的情况,立即抛出异常或终止操作,以避免更严重的问题发生。
在数据结构操作中,"Fail-fast" 机制通常会在检测到并发修改或违反数据结构约束时立即抛出异常,而不是继续进行可能导致数据不一致的操作。这样做的目的是确保问题能够被快速定位和修复,避免错误的蔓延。
"Fail-fast" 的应用范围广泛,包括以下情况:
- 集合类: 在使用迭代器遍历集合时,如果在迭代过程中集合被修改,"Fail-fast" 机制会立即抛出
ConcurrentModificationException
异常,避免迭代过程中的数据不一致。 - 并发编程: 在多线程环境中,当发现线程之间的竞争条件、死锁、饥饿等问题时,"Fail-fast" 机制会尽早暴露这些问题,而不是让程序继续执行下去可能导致更严重的并发问题。
- 数据验证: 在进行数据验证和处理时,如果遇到不合法的输入或违反数据约束的情况,"Fail-fast" 机制会立即报告错误,而不是继续进行可能导致错误扩散的操作。
总之,"Fail-fast" 机制在软件开发中起到了及早发现问题、及时报告错误的作用,有助于保障程序的稳定性、可靠性和可维护性。
十四、Thread类中的yield方法有什么作用?
Thread.yield()
方法是 Java 中的一个静态方法,用于提示线程调度器暂时让出 CPU 时间片,给其他具有相同优先级的线程一些机会来执行。
但需要注意的是,yield()
方法不保证一定会让出 CPU 的执行权,它只是提供一个提示,线程调度器可能会忽略这个提示,继续让当前线程执行。
yield()
方法的主要用途是在某些情况下帮助线程之间更好地协调和调度,例如:
- 平衡负载: 当有多个线程需要竞争执行,而你希望这些线程平均分配 CPU 时间,可以在适当的地方调用
yield()
方法。 - 避免线程饥饿: 如果有某个线程由于某些原因一直没有机会执行,你可以在其他线程中使用
yield()
来避免这个线程一直饥饿。 - 模拟和测试: 在特定的测试情况下,你可能需要模拟多个线程之间的竞争和调度情况,这时可以使用
yield()
方法来引入一些不确定性。
在实际场景中,使用 yield()
方法较少,因为操作系统的线程调度器在分配 CPU 时间时已经考虑了线程的优先级和调度策略。yield()
方法的使用可能会在某些情况下导致不稳定的行为,因为具体的线程调度行为可能会因操作系统、JVM 版本等因素而有所不同。更好的做法是使用更可靠的同步和调度机制来控制线程的执行
十五、Java线程池中submit() 和 execute()方法有什么区别?
submit()
方法和 execute()
方法都可以用于将任务提交到 Java 线程池中执行,但它们在一些方面有一些区别:
1. 返回值类型:
submit()
方法返回一个Future
对象,你可以通过这个对象来获取任务的执行结果、取消任务、或者等待任务完成。execute()
方法没有返回值,因此你无法获得任务的执行结果。
2. 异常处理:
- 使用
submit()
方法提交的任务,如果任务内部抛出异常,可以通过Future
对象的get()
方法捕获异常,并进行适当的处理。 - 使用
execute()
方法提交的任务,如果任务内部抛出异常,线程池内部会捕获并处理异常,但你无法在任务提交的地方捕获到异常。
3. 适用范围:
submit()
方法更加通用,它可以接受Runnable
和Callable
类型的任务,返回Future
对象。execute()
方法只接受Runnable
类型的任务,无返回值。
4. 扩展性:
submit()
方法在功能上更加强大,它允许你在任务执行前进行一些设置,如设置任务的执行超时时间等。execute()
方法相对较简单,适用于不需要获取任务执行结果的简单任务提交。
示例用法:
import java.util.concurrent.*;
public class ThreadPoolExample {
public static void main(String[] args) throws InterruptedException, ExecutionException {
ExecutorService executorService = Executors.newFixedThreadPool(2);
// 使用 submit() 方法
Future<String> future = executorService.submit(() -> {
return "Task executed";
});
System.out.println(future.get()); // 获取任务执行结果
// 使用 execute() 方法
executorService.execute(() -> {
System.out.println("Task executed");
});
executorService.shutdown();
}
}
总之,submit()
方法比较灵活,适用于更多的任务提交和结果处理场景,而 execute()
方法适用于简单的任务提交。根据具体的需求,选择适合的方法来提交任务到线程池。
十六、怎么使用 synchronized 关键字?
synchronized 可以修饰什么,属于什么种类的锁,有什么区别
synchronized
关键字可以修饰以下几种内容,并且对应不同种类的锁:
-
实例方法(实例锁):
- 修饰形式:
public synchronized void methodName() {...}
- 锁定的是实例对象,每个实例对象有自己的锁。
- 同一实例的不同方法间的调用会共享同一把锁。
- 修饰形式:
-
实例代码块(实例锁):
- 修饰形式:
public void someMethod() { synchronized (this) { // 同步代码块 } }
- 锁定的是指定的实例对象。
- 允许不同实例的线程同时访问不同实例的代码块。
或
- 修饰形式:
public class MyClass { private final Object lock = new Object(); public void method1() { synchronized (lock) { // 同步代码块1 } } public void method2() { synchronized (lock) { // 同步代码块2 } } }
-
在这个示例中,无论是调用 method1() 还是 method2(),同一时刻只有一个线程能够进入对应的同步代码块,因为它们都使用了同一个锁对象 lock。
-
如果你在一个类的不同方法中使用相同的锁对象,那么这些方法就会相互排斥,只有一个线程能够执行其中的一个方法,即使这些方法是在同一实例对象上调用的。
-
静态方法(类锁):
- 修饰形式:
public static synchronized void staticMethodName() {...}
- 锁定的是类的
Class
对象,适用于整个类的同步。 - 不同实例之间共享同一个类锁。
- 修饰形式:
-
类代码块(类锁):
- 修饰形式:
static { synchronized (ClassName.class) { // 同步代码块 } }
- 锁定的是类的
Class
对象。 - 不同实例之间共享同一个类锁。
这些种类的锁在作用范围、互斥性、同步对象等方面有以下区别:
- 实例锁: 锁定的是实例对象,不同实例之间的锁是独立的,不会互相影响。同一个实例的不同方法间共享同一把锁,只允许一个线程访问该实例的同步方法或代码块(修饰实例代码块时也称对象内部锁或监视器锁)。
- 类锁: 锁定的是类的
Class
对象,不同实例之间共享同一个类锁。适用于控制静态资源的并发访问。
选择适当的锁取决于你的同步需求。根据不同情况,你可能需要实例锁、类锁,或者自定义对象锁,以确保多线程环境下的数据安全。
16.1 给 Class 类上锁和给对象实例上锁的区别
在Java中,给 Class
类上锁和给对象实例上锁有一些区别,涉及到不同的锁和锁的作用范围:
1. 给 Class 类上锁:
- 在Java中,每个类都有一个对应的
Class
对象,它是类的元数据表示。可以使用synchronized
关键字来给Class
类对象上锁,从而实现类级别的同步。 - 当使用
synchronized
关键字给Class
类对象上锁时,这个锁是类级别的,它会影响同一类的所有实例对象。即使不同实例对象的锁不同,但它们共享同一个类级别的锁。 - 这种类级别的锁对于类的静态方法和类的静态变量访问是有效的。它可以防止多个线程同时访问类级别的共享资源。
2. 给对象实例上锁:
- 给对象实例上锁是指在实例方法内使用
synchronized
关键字,或者在代码块中对实例对象使用synchronized
来实现对象级别的同步。 - 每个对象实例都有一个与之关联的锁,通常称为监视器锁或内置锁。当一个线程持有一个对象实例的锁时,其他线程将无法同时获得相同实例的锁,从而防止多线程对同一实例对象的并发访问。
主要区别:
- 类级别的锁是共享的,适用于同一类的所有实例对象,防止对静态资源的并发访问。对象实例级别的锁是针对单个实例对象,适用于控制实例对象的并发访问。
- 类级别的锁影响同一类的所有实例对象,而对象实例级别的锁只影响持有该锁的实例对象。
- 类级别的锁通过对
Class
类对象使用synchronized
关键字实现,对象实例级别的锁通过synchronized
方法或代码块来实现。
需要根据具体的需求来选择合适的同步策略,以保证多线程环境下的数据安全和并发性。
16.2 结合生活场景说明给类锁和实例锁的区别
假设有一个购票系统,有多个窗口同时售票。我们可以使用生活场景来说明给 Class 类上锁和对象实例上锁的区别。
类锁场景: 在这个场景中,我们考虑所有窗口共享一个票池,每个窗口都从同一个票池中售票。我们需要确保多个窗口之间不会出现同时售卖同一张票的问题。
javaCopy code
public class TicketSystem {
private static int availableTickets = 50;
public static synchronized void sellTicket() {
if (availableTickets > 0) {
System.out.println("Window " + Thread.currentThread().getName() + " sold ticket " + availableTickets);
availableTickets--;
}
}
}
public class TicketSeller implements Runnable {
@Override
public void run() {
while (TicketSystem.availableTickets > 0) {
TicketSystem.sellTicket();
}
}
}
public class Main {
public static void main(String[] args) {
Thread t1 = new Thread(new TicketSeller(), "Window 1");
Thread t2 = new Thread(new TicketSeller(), "Window 2");
t1.start();
t2.start();
}
}
在上面的代码中,TicketSystem
类的 sellTicket()
方法是一个静态方法,使用了 synchronized
关键字。这就是给 Class 类上锁的情况。这样,无论是哪个窗口线程,都会竞争同一个类级别的锁,确保在售票时只有一个窗口能够进行操作。
实例锁场景: 现在,我们考虑每个窗口都是独立的,拥有自己的票池。我们需要确保同一窗口不会同时售卖同一张票。
public class TicketWindow {
private int availableTickets;
public TicketWindow(int availableTickets) {
this.availableTickets = availableTickets;
}
public synchronized void sellTicket() {
if (availableTickets > 0) {
System.out.println(Thread.currentThread().getName() + " sold ticket " + availableTickets);
availableTickets--;
}
}
}
public class TicketSeller implements Runnable {
private TicketWindow window;
public TicketSeller(TicketWindow window) {
this.window = window;
}
@Override
public void run() {
while (window.availableTickets > 0) {
window.sellTicket();
}
}
}
public class Main {
public static void main(String[] args) {
TicketWindow window1 = new TicketWindow(50);
TicketWindow window2 = new TicketWindow(50);
Thread t1 = new Thread(new TicketSeller(window1), "Window 1");
Thread t2 = new Thread(new TicketSeller(window2), "Window 2");
t1.start();
t2.start();
}
}
在这个例子中,每个窗口对象都有自己的锁,因为每个窗口的 sellTicket()
方法都是实例方法,使用了 synchronized
关键字。这样,每个窗口在售票时都会锁定自己的对象实例,确保同一窗口不会同时售卖同一张票。
总之,给 Class 类上锁和给对象实例上锁的区别在于锁的粒度和作用范围。给 Class 类上锁是为了控制同一类的所有对象实例的并发访问,而给对象实例上锁是为了控制特定对象实例的并发访问。
十七、说一说自己对于 synchronized 关键字的了解
当涉及多线程编程时,synchronized
关键字在 Java 中扮演着重要的角色,用于实现线程安全和同步。以下是关于 synchronized
关键字的一些要点:
- 线程安全: 多线程环境下,如果多个线程同时访问共享的数据或资源,可能会导致不一致的结果。
synchronized
关键字可以用于确保在同一时间只有一个线程能够访问共享资源,从而保证线程安全。 - 实现同步:
synchronized
关键字可以修饰方法、代码块、静态方法等,以确保在访问同步区域时,只有一个线程可以进入,其他线程需要等待。 - 锁的种类:
synchronized
锁的种类包括实例锁(对象锁)、类锁、对象内部锁(监视器锁)。不同的锁适用于不同的同步需求。 - 同步的代价:
synchronized
关键字虽然可以确保线程安全,但会引入性能开销。过多的同步可能导致性能问题,因此在设计中要注意同步的粒度。 - 避免死锁: 当多个线程相互等待对方释放锁时,可能会出现死锁。避免死锁需要合理地管理锁的获取和释放顺序。
- 替代方式: 除了
synchronized
关键字外,还有一些并发工具类如ReentrantLock
、Semaphore
等,可以提供更灵活的同步控制。 - 优化建议: 在进行多线程编程时,需要合理地使用
synchronized
关键字来保证线程安全,同时也要考虑锁的范围和粒度,以及其他并发问题。
总之,synchronized
关键字是 Java 中用于实现线程同步和保证多线程环境下数据安全的重要工具,但在使用时需要谨慎考虑锁的种类和使用方式。
十八、锁的优化机制/在 Java 6 之后 Java 官方对从 JVM 层面对 synchronized 做了哪些优化
在 Java 6 及其之后的版本中,Java 官方对 synchronized
进行了一些重要的优化,以提升多线程同步的性能。这些优化旨在减少锁竞争、减小同步开销、提高并发性能。以下是一些 JVM 层面的 synchronized
优化:
- 锁膨胀(Lock Inflation): 在 Java 6 之前,JVM 使用重量级锁(互斥量)来保护每个对象的同步代码块。这会引入较大的开销,尤其是在线程竞争较少的情况下。从 Java 6 开始,引入了轻量级锁和偏向锁来优化锁的使用。轻量级锁在多线程竞争不激烈的情况下使用 CAS 操作来避免阻塞,而偏向锁则针对只有一个线程访问的情况。
- 适应性自旋(Adaptive Spinning): Java 6 引入了适应性自旋,即在多次尝试获取锁后,如果线程发现获取锁的代价较大,它会自动放弃自旋,避免浪费 CPU 时间。
- 锁消除(Lock Elimination): JVM 在运行时分析代码,判断某些锁不会被多个线程同时访问,从而进行锁消除,减少不必要的同步开销。
- 锁粗化(Lock Coarsening): JVM 在运行时可能会将多个连续的独立的锁操作合并成一个范围更大的锁操作,从而减少锁竞争的频率,提高性能。
- 偏向锁(Biased Locking): Java 6 引入了偏向锁机制,当只有一个线程访问同步块时,可以将对象头部的部分标记为偏向锁,使得该线程能够快速获取锁,而其他线程则不会竞争。这避免了不必要的竞争和锁膨胀。如果有其他线程尝试访问时,偏向锁会自动升级为轻量级锁。
- 轻量级锁(Lightweight Locking): 轻量级锁是一种在多线程竞争较少的情况下优化同步的机制。轻量级锁是一种用于多个线程交替访问同步代码块的优化机制。当一个线程尝试获取锁时,它会使用 CAS(比较并交换)操作来试图将对象头部的标志位设置为轻量级锁。如果成功,线程进入临界区,如果失败,表示有其他线程竞争,升级为重量级锁。
这些优化在 Java 6 以及之后的版本中,极大地提升了 synchronized
的性能,使得在许多场景下,使用 synchronized
依然是一种高效的同步机制。然而,开发者仍然需要谨慎设计并发程序,以充分利用这些优化并避免潜在的问题,比如死锁、竞态条件等。
CAS的原理和缺点
CAS(Compare and Swap)是一种并发编程中常用的原子操作,用于实现多线程环境下的线程安全操作。它是一种乐观锁机制,基于硬件原语实现,常用于解决并发访问共享数据时的竞争问题。CAS 操作包括三个操作数:内存位置、期望值和新值。其基本原理如下:
- 比较: 首先,CAS 会比较内存位置中的值与期望值是否相等。如果相等,则继续执行下一步;如果不相等,则操作失败,不会更新值。
- 交换: 如果比较相等,CAS 会将内存位置的值更新为新值。这一步是一个原子操作,保证了并发操作的正确性。
- 返回: CAS 操作会返回操作之前内存位置的值。
CAS 操作的主要优点在于它是原子的,不需要加锁,因此能够避免了锁带来的性能开销和死锁风险。然而,CAS 也存在一些问题:
- ABA 问题: 如果一个值在 CAS 操作之前被改为其他值,然后再改回原来的值,CAS 无法感知这种变化。这可能导致一些意外的行为。从Java1.5开始JDK的atomic包里提供了一个类AtomicStampedReference来解决ABA问题。
- 循环开销: CAS 操作在失败时需要不断尝试,如果有多个线程同时在尝试,会造成一些循环开销,效率低于synchronized。
- 只能保证一个共享变量的原子操作 : 当对一个共享变量执行操作时,我们可以使用循环CAS的
方式来保证原子操作,但是对多个共享变量操作时,循环CAS就无法保证操作的原子性,这个
时候就可以用锁。
尽管 CAS 存在一些限制,但它在很多并发编程场景中都有着很好的性能表现。Java 提供了 java.util.concurrent.atomic
包,包含了许多基于 CAS 的原子类,如 AtomicInteger
、AtomicLong
等,方便开发者进行线程安全的原子操作。
十九、Vector 是如何实现线程安全的
Vector
是 Java 中的一个线程安全的集合类,它实现了线程安全的机制,以便多个线程可以同时访问和修改它,而不会导致不一致或竞态条件。Vector
的线程安全性是通过同步方法来实现的。以下是 Vector
实现线程安全的主要机制:
- 同步方法:
Vector
中的所有公共方法都使用了synchronized
关键字来保证线程安全。这意味着在调用这些方法时,会对Vector
对象进行加锁,以确保同一时间只有一个线程能够访问和修改集合。 - 方法级别的同步:
Vector
的方法级别同步确保了在单个方法的执行期间,不会有其他线程同时调用其他同步方法。这样可以避免多个线程同时访问数据,从而保证数据的一致性。 - 性能开销: 虽然同步方法确保了线程安全,但同时也引入了性能开销。因为每个方法都需要获取锁,可能会造成竞争和阻塞,从而降低整体性能。
- 适用场景:
Vector
适用于需要线程安全的情况,但在大多数情况下,由于同步开销较大,推荐使用更现代的并发集合类,如ArrayList
的同步包装类Collections.synchronizedList
,或者CopyOnWriteArrayList
等。
需要注意的是,尽管 Vector
是线程安全的,但在编写多线程代码时,仍然需要仔细考虑同步和并发问题,以确保正确的程序行为。
集合中哪些是线程安全
以下是一些 Java 中常见的线程安全的集合单位,它们可以在多线程环境下安全使用:
Vector
:Vector
是一个线程安全的动态数组,支持在多线程环境中进行增删改查操作。- 同步集合类: Java 提供了一系列同步的集合类,如
Collections.synchronizedList
、Collections.synchronizedSet
、Collections.synchronizedMap
等。它们通过对每个方法加锁来保证线程安全。 - 并发集合类: Java 提供了一系列并发的集合类,如
ConcurrentHashMap
、ConcurrentSkipListSet
等。它们采用更细粒度的锁机制,以提供更好的并发性能。 - 并发队列: 并发队列是专门用于多线程生产者-消费者模型的集合,如
LinkedBlockingQueue
、ArrayBlockingQueue
等。它们支持高并发的入队和出队操作。 - 并发映射:
ConcurrentHashMap
是一个线程安全的键值对映射集合,提供高并发的读写操作。 - 并发集:
ConcurrentSkipListSet
是一个线程安全的有序集合,支持高并发操作。
这些线程安全的集合单位可以在多线程环境下安全地使用,但仍需注意遵循适当的使用方式和同步策略,以充分发挥其线程安全性和性能优势。
二十、常用的线程池有哪些?
在 Java 中,有几种常用的线程池实现,它们都位于 java.util.concurrent
包中,用于管理和调度线程的执行。以下是一些常用的线程池实现:
Executors.newFixedThreadPool(int nThreads)
: 创建一个固定大小的线程池,线程数固定不变。当线程池中的线程都在执行任务时,新的任务会在队列中等待。Executors.newCachedThreadPool()
: 创建一个缓存线程池,线程数根据需要动态调整。空闲线程会被回收,当任务增加时会创建新线程。Executors.newScheduledThreadPool(int corePoolSize)
: 创建一个定时任务线程池,可以执行定时或周期性任务。Executors.newSingleThreadExecutor()
: 创建一个单线程的线程池,只有一个线程在执行任务。常用于需要顺序执行任务的场景。ThreadPoolExecutor
类: 这是一个更灵活、可定制的线程池类,可以通过构造函数指定核心线程数、最大线程数、任务队列、拒绝策略等参数。
在实际开发中,建议使用 ThreadPoolExecutor
或者根据业务需求来创建自定义的线程池,以便更好地满足特定场景下的线程管理和资源控制需求。同时,了解线程池的工作原理和参数配置对于避免资源浪费、提高性能和稳定性也很重要。
二一、简述一下你对线程池的理解
线程池是一种用于管理和重用线程的机制,它可以有效地管理线程的生命周期、减少线程创建和销毁的开销,从而提高多线程程序的性能和资源利用率。线程池将一组可重用的线程放入池中,并根据需要从池中获取线程执行任务,执行完任务后将线程放回池中,以便后续使用。
线程池的好处在于,它能够根据任务的情况来自动调整线程数量,避免了线程过多导致的资源浪费,同时也避免了频繁地线程创建和销毁带来的性能开销。线程池还能够提供一些额外的功能,如任务调度、线程池状态管理、异常处理等。通过合理地配置线程池的参数,可以在多线程程序中提高并发性能和效率。
二二、线程池核心线程数怎么设置呢?
设置线程池的核心线程数需要考虑多个因素,包括系统的资源情况、任务的性质以及并发负载等。以下是一些指导原则来帮助你确定适当的线程池核心线程数:
- 任务的性质: 如果任务是 CPU 密集型的,即需要大量的计算操作,可以将线程数设置为 N(CPU 核心数)+1。 如果任务是 I/O 密集型的,即涉及到 I/O 操作(如读写文件、网络通信等),则可以设置核心线程数=CPU核心数量*2,以便在 I/O 阻塞时可以切换执行其他任务。
- 并发负载: 核心线程数应该根据预期的并发负载来确定。如果系统需要同时处理多个任务,可以适当增加核心线程数,以保证足够的并发处理能力。不过,设置过多的核心线程数也可能导致资源浪费和线程竞争。
- 系统资源: 考虑系统的资源情况,包括 CPU 核心数、内存等。不建议设置过多的核心线程数,以免消耗过多的系统资源。
- 任务队列: 如果线程池的任务队列较大,可以适当减少核心线程数,因为任务可以在队列中等待,无需立即分配线程执行。
- 响应时间: 考虑系统对任务响应时间的要求。核心线程数的设置会影响任务的响应时间,可以根据业务需求来平衡。
需要注意的是,核心线程数的设置并不是一成不变的,应该根据实际情况进行调整和优化。可以通过监控系统的运行状态,例如线程池的平均负载、任务等待时间等指标,来评估核心线程数的合适性,并根据实际性能进行调整。同时,也可以通过多次测试和实验来找到最适合的核心线程数。
二三、线程池原理知道吗?以及核心参数
首先线程池有几个核心的参数概念:
- 最大线程数 maximumPoolSize
- 核心线程数 corePoolSize
- 活跃时间 keepAliveTime
- 阻塞队列 workQueue
- 拒绝策略 RejectedExecutionHandler
当提交一个新任务到线程池时,具体的执行流程如下:
- 当我们提交任务,线程池会根据corePoolSize大小创建若干任务数量线程执行任务
- 当任务的数量超过corePoolSize数量,后续的任务将会进入阻塞队列阻塞排队
- 当阻塞队列也满了之后,那么将会继续创建(maximumPoolSize-corePoolSize)个数量的线程来执行任务,如果任务处理完成,maximumPoolSize-corePoolSize额外创建的线程等待 keepAliveTime之后被自动销毁
- 如果达到maximumPoolSize,阻塞队列还是满的状态,那么将根据不同的拒绝策略对应处理
二四、Java线程池中队列常用类型有哪些?
ArrayBlockingQueue
是一个基于数组结构的有界阻塞队列,此队列按 FIFO(先进先出)原则对元素进行排序。
LinkedBlockingQueue
一个基于链表结构的无界阻塞队列,此队列按FIFO (先进先出)
排序元素,吞吐量通常要高于 ArrayBlockingQueue 。
PriorityBlockingQueue
一个基于最小二叉堆实现的无界优先级队列,任务会根据优先级进行排序,优先级高的任务先被执行。可以用于对任务进行排序和调度。
DelayQueue
用于存储实现了 Delayed
接口的元素,元素在一定延迟时间后才能被取出。适用于需要延迟执行的任务,如定时任务调度。DelayQueue 是一个没有大小限制的队列,因此往队列中插入数据的操作(生产者)永远不会被阻塞,而只有获取数据的操作(消费
者)才会被阻塞。
二五、线程池的拒绝策略有哪些?
Java 线程池的拒绝策略用于处理线程池和任务队列都已满,无法继续处理新任务时的情况。以下是一些常见的线程池拒绝策略:
AbortPolicy
(默认): 抛出一个RejectedExecutionException
异常,通知调用者任务被拒绝。这是默认的拒绝策略。CallerRunsPolicy
: 将被拒绝的任务返回给调用者线程来执行。这种方式可能会降低任务提交速度,但可以保证不会丢失任务。DiscardPolicy
: 直接丢弃被拒绝的任务,不做任何处理。任务被丢弃后,不会有任何通知或异常抛出。DiscardOldestPolicy
: 丢弃队列中最旧的任务(队列头部),然后尝试提交新任务。这可能会导致一些旧的任务被丢弃,但可以保留最新的任务。
除了以上四种基本的拒绝策略,还可以根据业务需求自定义拒绝策略,实现 RejectedExecutionHandler
接口,自定义拒绝策略的逻辑。
选择适当的拒绝策略取决于应用场景和需求。例如,如果对任务的处理顺序要求较高,可以选择 CallerRunsPolicy
或自定义策略;如果任务提交频繁且重要性不同,可以选择 PriorityBlockingQueue
配合合适的拒绝策略。不同的拒绝策略会对应不同的处理方式,需要根据实际情况选择。
二六、什么是阻塞队列?阻塞队列的实现原理是什么?如何使用阻 塞队列来实现生产者-消费者模型?
阻塞队列是一种特殊类型的队列,具有阻塞操作的特性。在多线程环境下,当队列为空时,从队列中获取元素的操作会被阻塞,直到队列中有可用元素为止;当队列已满时,向队列中添加元素的操作也会被阻塞,直到队列有空闲位置为止。阻塞队列在并发编程中常用于实现生产者-消费者模型、线程池等场景。
阻塞队列的实现原理可以简述如下:
- 阻塞队列内部通常使用数组或链表等数据结构来存储元素。
- 阻塞队列会维护两个锁,一个用于控制入队操作,另一个用于控制出队操作。
- 当入队操作发生时,如果队列已满,入队操作会被阻塞,直到队列有空闲位置。入队线程会被挂起,并释放入队锁,直到其他线程将元素出队,唤醒入队线程。
- 当出队操作发生时,如果队列为空,出队操作会被阻塞,直到队列有可用元素。出队线程会被挂起,并释放出队锁,直到其他线程将元素入队,唤醒出队线程。
阻塞队列的使用可以实现生产者-消费者模型,其中生产者线程负责向队列中添加元素,消费者线程负责从队列中取出元素。使用阻塞队列可以有效地实现线程间的协调和通信,而不需要显式地使用锁或信号量。
以下是一个简单的生产者-消费者模型的示例,使用 ArrayBlockingQueue
实现:
import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.BlockingQueue;
public class ProducerConsumerExample {
public static void main(String[] args) {
BlockingQueue<Integer> queue = new ArrayBlockingQueue<>(10);
Thread producer = new Thread(() -> {
try {
for (int i = 1; i <= 10; i++) {
queue.put(i); // 阻塞地添加元素
System.out.println("Produced: " + i);
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
Thread consumer = new Thread(() -> {
try {
for (int i = 1; i <= 10; i++) {
int value = queue.take(); // 阻塞地取出元素
System.out.println("Consumed: " + value);
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
producer.start();
consumer.start();
}
}
在这个例子中,ArrayBlockingQueue
被用作阻塞队列,生产者线程往队列中放入元素,消费者线程从队列中取出元素。队列的阻塞特性使得生产者和消费者能够自动地协调执行。
二七、ThreadLocal的实现原理
ThreadLocal可以理解为线程本地变量,他会在每个线程都创建一个副本,那么在线程之间访问内部副本变量就行了,做到了线程之间互相隔离,相比于synchronized的做法是用空间来换时间。
ThreadLocal有一个静态内部类ThreadLocalMap,ThreadLocalMap又包含了一个Entry数组,Entry本身是一个弱引用,他的key是指向ThreadLocal的弱引用,Entry具备了保存key value键值对的能力。
ThreadLocal
的工作原理可以简要描述如下:
- 数据结构: 每个
ThreadLocal
实例内部维护一个数据结构,通常是一个以当前线程为 key、变量副本为 value 的哈希表。 - 获取变量副本: 当线程第一次访问
ThreadLocal
实例的get()
或set()
方法时,会先检查当前线程是否已经有了与之对应的变量副本。如果没有,则会在当前线程的哈希表中创建一个新的变量副本,并将初始值赋予它。 - 存储和访问变量: 当线程访问
ThreadLocal
实例的get()
方法时,实际上是在当前线程的哈希表中查找与之对应的变量副本,并返回其值。当线程访问ThreadLocal
实例的set()
方法时,会在当前线程的哈希表中更新对应的变量副本的值。 - 自动清理:
ThreadLocal
有一个特点是,一旦线程结束,与之关联的变量副本也会被自动清理,这有助于防止内存泄漏。
总之,ThreadLocal
实现了多线程环境下的线程隔离,每个线程都可以独立地访问自己的变量副本,而不会影响其他线程的副本。然而,需要注意的是,滥用 ThreadLocal
可能会引发问题,比如可能导致资源泄漏、难以追踪问题等。
1. ThreadLocalMap
除了上述属性外,还有一个重要的属性 ThreadLocalMap,ThreadLocalMap 是 ThreadLocal 的静态内部类,当一个线程有多个 ThreadLocal 时,需要一个容器来管理多个 ThreadLocal,ThreadLocalMap 的作用就是管理线程中多个 ThreadLocal,源码如下:
static class ThreadLocalMap {
/**
* 键值对实体的存储结构
*/
static class Entry extends WeakReference<ThreadLocal<?>> {
// 当前线程关联的 value,这个 value 并没有用弱引用追踪
Object value;
/**
* 构造键值对
*
* @param k k 作 key,作为 key 的 ThreadLocal 会被包装为一个弱引用
* @param v v 作 value
*/
Entry(ThreadLocal<?> k, Object v) {
super(k);
value = v;
}
}
// 初始容量,必须为 2 的幂
private static final int INITIAL_CAPACITY = 16;
// 存储 ThreadLocal 的键值对实体数组,长度必须为 2 的幂
private Entry[] table;
// ThreadLocalMap 元素数量
private int size = 0;
// 扩容的阈值,默认是数组大小的三分之二
private int threshold;
}
从源码中看到 ThreadLocalMap 其实就是一个简单的 Map 结构,底层是数组,有初始化大小,也有扩容阈值大小,数组的元素是 Entry,「Entry 的 key 就是 ThreadLocal 的弱引用,value 是 ThreadLocal 的值」。ThreadLocalMap 解决 hash 冲突的方式采用的是 线性探测法,如果发生冲突会继续寻找下一个空的位置。
2. ThreadLocalMap内存泄漏
如前文所述,ThreadLocalMap其实是一个ThreadLocal --> value的映射,具体的实现关系如下图
ThreadLocal 在没有外部强引用时,发生 GC 时会被回收,那么 ThreadLocalMap 中保存的 key 值就变成了 null,而 Entry 又被 threadLocalMap 对象引用,threadLocalMap 对象又被 Thread 对象所引用,那么当 Thread 一直不终结的话,value 对象就会一直存在于内存中,也就导致了内存泄漏,直至 Thread 被销毁后,才会被回收。
那么如何避免内存泄漏呢?
ThreadLocal的set/get/remove方法中在遇到key==null的节点时(被称为stale腐烂节点),会进行清理等处理逻辑。
因此,必须在使用了ThreadLocal的线程执行完后finally中调用threadLocal.remove()
,或者如果ThreadLocal<HashMap>
的话则调用threadlocal.get().remove()
清空HashMap
二七、引用类型有哪些?有什么区别?
在 Java 中,有四种不同的引用类型:强引用、软引用、弱引用和虚引用。它们在对象的生命周期和垃圾回收过程中有不同的行为。以下是它们之间的区别:
-
强引用(Strong Reference):
- 强引用是最常见的引用类型。当一个对象具有强引用时,即使内存不足,垃圾回收器也不会回收该对象。只有当没有任何强引用指向一个对象时,该对象才会被回收。
- 使用普通的对象引用赋值,即
Object obj = new Object()
,就是创建一个强引用。
-
软引用(Soft Reference):
- 软引用允许对象在内存不足时被回收。软引用的对象只有在内存不足时才会被垃圾回收器回收,这样可以避免内存溢出问题。
- 可以使用
SoftReference
类创建软引用,例如SoftReference<Object> softRef = new SoftReference<>(object)
。
-
弱引用(Weak Reference):
- 弱引用与软引用类似,但更弱。即使内存充足,垃圾回收器在发现弱引用的对象时也会立即回收该对象。
- 可以使用
WeakReference
类创建弱引用,例如WeakReference<Object> weakRef = new WeakReference<>(object)
。
-
虚引用(Phantom Reference):
- 虚引用是最弱的引用类型。它的存在几乎不会影响对象的生命周期。虚引用主要用于跟踪对象被垃圾回收器回收的活动,它不能单独使用来获取对象。同样的当发生GC的时候,虚引用也会被回收。可以用虚引用来管理堆外内存。
- 可以使用
PhantomReference
类创建虚引用,例如PhantomReference<Object> phantomRef = new PhantomReference<>(object, referenceQueue)
。
二八、产生死锁的四个必要条件?如何避免死锁?
- 互斥条件(Mutual Exclusion): 至少有一个资源处于被独占状态,即一次只能被一个进程或线程使用。
- 请求与保持条件(Hold and Wait): 进程或线程至少持有一个资源,并且在等待获取其他资源的时候,不释放已经持有的资源。
- 不可剥夺条件(No Preemption): 已经被分配给进程或线程的资源不能被强制性地抢占,只能由持有资源的进程或线程显式地释放。
- 循环等待条件(Circular Wait): 一组进程或线程之间形成了等待循环,其中每个进程或线程都在等待下一个进程或线程所持有的资源。
只有当这四个条件同时满足时,死锁才会发生。死锁是多线程或多进程环境中常见的问题,会导致系统无法继续执行下去,因为所有参与死锁的线程或进程都在等待对方释放资源,从而无法继续工作。避免死锁的常见方法包括破坏死锁的四个必要条件中的一个或多个,如通过资源的预分配、使用超时、资源的有序分配等方法来避免互斥、请求与保持、不可剥夺和循环等待等情况。
资源的有序分配,举例如下:
- 比如某个线程只有获得A锁和B锁才能对某资源进行操作,在多线程条件下,如何避免死锁?
- 获得锁的顺序是一定的,比如规定,只有获得A锁的线程才有资格获取B锁,按顺序获取锁就可以避免死锁!!!
二九、进程和线程的区别
- 进程是一个“执行中的程序”,是系统进行资源分配和调度的一个独立单位;线程是进程内的执行单元。
- 线程是进程的一个实体,一个进程中拥有多个线程,进程之间独立,线程之间共享进程资源。
- 进程切换开销大,线程切换开销相对小。
- 进程通信需要特定机制,线程通信更简便。
在多核处理器和多线程编程中,线程被广泛用于并发执行任务,因为线程的切换开销较小,更适合于需要频繁切换的场景。进程则更适合于隔离不同应用程序、提供更强的安全性等需要独立执行环境的情况。
三十、线程之间是如何通信的?
在线程间进行通信是多线程编程中的一个关键问题,因为多个线程在共享资源和协同工作的过程中需要进行信息的传递和同步。主要的线程间通信机制包括以下几种:
- 共享内存(Shared Memory): 多个线程共享同一块内存空间,在这块内存中存储共享数据。通过读写共享内存中的数据,线程可以进行信息的传递。但是需要注意,由于没有直接控制权,共享内存可能导致数据竞争和同步问题,需要使用同步机制来保证数据的一致性。
- 消息传递(Message Passing): 线程通过发送消息来进行通信。每个线程都有自己的消息队列,当一个线程想要与另一个线程通信时,它将消息放入目标线程的队列中,目标线程在合适的时候从队列中读取消息。消息传递机制可以避免数据竞争,但需要注意消息的顺序和同步问题。
- 管程(Monitor): 管程是一种高级的同步机制,通过封装共享数据和对共享数据的操作,确保在同一时间只有一个线程可以访问共享数据。管程中的方法可以被线程调用,而管程内部会自动处理同步问题,使得线程间通信更加方便。
- 信号量(Semaphore): 信号量是一种控制访问共享资源的机制,可以用来限制同时访问共享资源的线程数量。线程可以通过信号量来申请和释放资源,从而实现线程间的协同工作。
- 条件变量(Condition Variable): 条件变量用于在线程之间进行等待和通知。一个线程可以等待某个条件满足,另一个线程可以通过发出通知来唤醒等待的线程。条件变量通常与锁结合使用,用于实现线程的等待和唤醒。
选择适当的线程间通信机制取决于具体的应用场景和需求。不同的机制有不同的适用情况和复杂度,开发者需要根据实际情况来选择最合适的通信方式。
三一、什么是多线程中的上下文切换?
上下文切换是指操作系统从一个正在运行的线程(或进程)切换到另一个线程(或进程)的过程。每个线程都有自己的上下文,包括程序计数器、寄存器、栈等信息。上下文切换涉及保存当前线程的上下文信息,加载下一个线程的上下文信息,以便实现线程之间的切换。从这个角度来看,上下文切换有点像我们同时阅读几本书,在来回切换书本的同时我们需要记住每本书当前读到的页码。
上下文切换通常发生在以下情况:
- 时间片用完: 操作系统将每个线程分配一个时间片,在时间片用完后,系统会保存当前线程的上下文并切换到另一个线程执行。
- 阻塞和唤醒: 当一个线程需要等待某个事件发生时(例如 I/O 操作的完成),它会被阻塞,此时操作系统会切换到另一个可以执行的线程。当事件发生时,操作系统会唤醒被阻塞的线程,导致上下文切换。
上下文切换是一项开销较大的操作,因为它涉及到寄存器状态的保存和恢复,需要执行一些额外的指令。在高并发的多线程应用中,频繁的上下文切换可能会导致性能下降。因此,优化上下文切换成为了一项重要的任务。
一些情况下,上下文切换是不可避免的,特别是在多核处理器上。然而,通过合理的线程调度策略、减少线程数、避免线程间频繁的锁竞争等方法,可以减少上下文切换带来的性能损失。
三二、说说CyclicBarrier和CountDownLatch的区别?
CyclicBarrier
和 CountDownLatch
都是 Java 并发包中用于线程协同的工具类,但它们在功能和用法上有一些区别。
-
功能:
CyclicBarrier
(循环屏障):CyclicBarrier
可以用于多个线程等待彼此达到共同的屏障点,然后同时开始继续执行。一旦所有线程都到达屏障点,屏障就会打开,所有线程都会继续执行。CyclicBarrier
可以被重置,可以在循环中使用,因此适用于多个线程协同完成阶段性任务。CountDownLatch
(倒计时门闩):CountDownLatch
用于一个或多个线程等待其他线程完成特定数量的任务,然后继续执行。它的计数器一开始被设置为一个初始值,每个完成任务的线程都会减少计数器的值,直到计数器为零,等待的线程会被释放。
-
用法:
CyclicBarrier
可以用于多个线程在某一阶段完成后同步,然后继续执行下一阶段的任务。常用于分阶段的计算和协同任务。CountDownLatch
适用于一组线程等待另一组线程完成特定数量的任务,通常用于主线程等待多个工作线程完成。
-
重用性:
CyclicBarrier
可以被重复使用。一旦所有线程到达屏障,它会重置,可以被用于多次循环。CountDownLatch
在计数器达到零后不能被重置,一旦计数器为零,它就不能再次使用。
综上所述,CyclicBarrier
和 CountDownLatch
都是用于线程协同的工具,但用法和场景略有不同。选择哪种工具取决于你的具体需求,是需要多个线程在某一阶段同步,还是需要等待其他线程完成特定数量的任务。
使用 CyclicBarrier
和 CountDownLatch
的两个简单示例代码
CyclicBarrier 示例: 假设有三个线程 A、B 和 C,它们在不同阶段等待,然后一起继续执行。
javaCopy code
import java.util.concurrent.CyclicBarrier;
public class CyclicBarrierExample {
public static void main(String[] args) {
int totalThreads = 3;
CyclicBarrier barrier = new CyclicBarrier(totalThreads);
Runnable task = () -> {
System.out.println(Thread.currentThread().getName() + " is waiting.");
try {
barrier.await(); // 等待所有线程到达
} catch (Exception e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName() + " is continuing.");
};
Thread threadA = new Thread(task, "Thread A");
Thread threadB = new Thread(task, "Thread B");
Thread threadC = new Thread(task, "Thread C");
threadA.start();
threadB.start();
threadC.start();
}
}
CountDownLatch 示例: 假设有一个主线程等待两个子线程完成后再继续执行。
javaCopy code
import java.util.concurrent.CountDownLatch;
public class CountDownLatchExample {
public static void main(String[] args) {
CountDownLatch latch = new CountDownLatch(2);
Runnable task = () -> {
System.out.println(Thread.currentThread().getName() + " is working.");
latch.countDown(); // 任务完成,减少计数器
};
Thread thread1 = new Thread(task, "Thread 1");
Thread thread2 = new Thread(task, "Thread 2");
thread1.start();
thread2.start();
try {
latch.await(); // 等待计数器减少到零
System.out.println("Both threads are done. Continue main thread.");
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
在这两个示例中,CyclicBarrier
用于多个线程在不同阶段等待,并在所有线程都到达时继续执行,而 CountDownLatch
用于主线程等待子线程完成特定数量的任务后继续执行。
三三、什么是AQS
AQS(AbstractQueuedSynchronizer)是 Java 并发框架中的一个抽象基类,用于构建同步器(synchronizer)和锁的底层框架。通俗地说,AQS是一个用于实现各种锁和同步器的模板,它帮助开发者在多线程环境下实现线程安全和协同的机制。
它通过内部维护一个双向队列(FIFO等待队列)和一个状态变量来管理线程的同步操作。它提供了 acquire
和 release
等方法,让子类实现不同类型的同步器逻辑。
AQS 主要分为两种同步器模式:
- 独占模式(Exclusive Mode): 适用于只允许一个线程获取锁的场景,如 ReentrantLock。
- 共享模式(Shared Mode): 适用于允许多个线程同时获取锁的场景,如 CountDownLatch、Semaphore。
三四、了解Semaphore吗?
Semaphore就是一个信号量,它的作用是限制某段代码块的并发数。Semaphore有一个构造函数,可以传入一个int型整数n,表示某段代码最多只有n个线程可以访问,如果超出了n,那么请等待,等到某个线程执行完毕这段代码块,下一个线程再进入。由此可以看出如果Semaphore构造函数中传入的int型整数n=1,相当于变成了一个synchronized了。 一个 Semaphore 通常有两个主要操作:
acquire()
:线程尝试获取一个许可证。如果没有可用的许可证,线程将被阻塞,直到有许可证可用。release()
:线程释放一个许可证,使得其他等待许可证的线程可以继续执行。
总之,Semaphore 是一种控制多个线程对共享资源访问的机制,通过限制许可证的数量来实现资源的有效管理。
三五、Java程序是如何执行的
Java 程序的执行流程基本都是相同的,它的执行流程如下:
- 先把 Java 代码编译成字节码,也就是把 .java 类型的文件编译成 .class 类型的文件。这个过程的大致执行流程:Java 源代码 -> 词法分析器 -> 语法分析器 -> 语义分析器 -> 字符码生成器 ->最终生成字节码,其中任何一个节点执行失败就会造成编译失败;
- 把 class 文件放置到 Java 虚拟机,这个虚拟机通常指的是 Oracle 官方自带的 Hotspot JVM;
- Java 虚拟机使用类加载器(Class Loader)装载 class 文件;
- 类加载完成之后,会进行字节码效验,字节码效验通过之后 JVM 解释器会把字节码翻译成机器码交由操作系统执行。但不是所有代码都是解释执行的,JVM 对此做了优化,比如,以 Hotspot 虚拟机来说,它本身提供了 JIT(Just In Time)也就是我们通常所说的动态编译器,它能够在运行时将热点代码编译为机器码,这个时候字节码就变成了编译执行。
Java 程序执行流程图如下:
三六、乐观锁和悲观锁
三七、CopyOnWrite 思想及其应用场景
CopyOnWrite(写入时复制)思想
CopyOnWrite(简称COW,中文意思是:写入时复制)就是在进行写操作时,先复制要改变的对象,对副本进行写操作,完成对副本的操作后,把原有对象的引用指向副本对象。
COW原理
CopyOnWrite采用了读写分离的思想解决了线程安全且支持读多写少等问题
关键点
- CopyOnWrite适用于读多写少的情况,最大程度的提高读的效率;
- CopyOnWrite是最终一致性,在写的过程中,原有的读的数据是不会发生更新的,只有新的读才能读到最新数据;
- 如何使其他线程能够及时读到新的数据,需要使用volatile变量;
- 写的时候不能并发写,需要对写操作进行加锁;
CopyOnWriteArrayList的实现原理
/** The lock protecting all mutators */
final transient ReentrantLock lock = new ReentrantLock();
/** The array, accessed only via getArray/setArray. */
private transient volatile Object[] array;
/**
* Appends the specified element to the end of this list.
*
* @param e element to be appended to this list
* @return {@code true} (as specified by {@link Collection#add})
*/
public boolean add(E e) {
final ReentrantLock lock = this.lock;
lock.lock();
try {
Object[] elements = getArray();
int len = elements.length;
Object[] newElements = Arrays.copyOf(elements, len + 1);
newElements[len] = e;
setArray(newElements);
return true;
} finally {
lock.unlock();
}
}
复制
volatile
volatile (挥发物、易变的):变量修饰符,只能用来修饰变量。volatile修饰的成员变量在每次被线程访问时,都强迫从共享内存中重读该成员变量的值。而且,当成员变量发生变 化时,强迫线程将变化值回写到共享内存。这样在任何时刻,两个不同的线程总是看到某个成员变量的同一个值。
transient
transient 只能用来修饰字段。在对象序列化的过程中,标记为transient的变量不会被序列化。
CopyOnWrite的缺点
CopyOnWrite有很多优点,但是同时也存在两个问题,即内存占用问题和数据一致性问题。所以在开发的时候需要注意一下。
内存占用问题。
由于写操作的时候,需要拷贝数组,会消耗内存,如果原数组元素比较大的情况下,可能导致频繁young gc或者full gc,应用响应时间也随之变长。 针对内存占用问题,可以通过压缩容器中的元素的方法来减少大对象的内存消耗,比如,如果元素全是10进制的数字,可以考虑把它压缩成36进制或64进制。或者不使用CopyOnWrite容器,而使用其他的并发容器,如ConcurrentHashMap。
数据一致性问题。
CopyOnWrite容器只能保证数据的最终一致性,不能保证数据的实时一致性。所以如果你希望写入的的数据,马上能读到,请不要使用CopyOnWrite容器。
三八、ConcurrentHashMap是如何保证线程安全的?
1、ConcurrentHashMap在JDK 1.7中使用的数组 加 链表的结构,其中数组分为两类,大树组Segment 和 小数组 HashEntry,而加锁是通过给Segment添加ReentrantLock重入锁来保证线程安全的。
2、ConcurrentHashMap在JDK1.8中使用的是数组 加 链表 加 红黑树(当链表长度大于8,并且数组长度大于64时,链表就会升级为红黑树的结构)的方式实现,它是通过 CAS 或者 synchronized 来保证线程安全的,并且缩小了锁的粒度,查询性能也更高。
为什么在有Synchronized 的情况下还要使用CAS
因为CAS是乐观锁,在一些场景中(并发不激烈的情况下)它比Synchronized和ReentrentLock的效率要高,当CAS保障不了线程安全的情况下(扩容或者hash冲突的情况下)转成Synchronized 来保证线程安全,大大提高了低并发下的性能.
为什么ConcurrentHashMap不允许插入null值?
ConcurrentHashMap在源码中加入不允许插入 null (空) 值的设计,主要目的是为了防止并发场景下的歧义问题。
那HashMap允许插入 null(空) 值,难道它就不担心出现歧义吗?这是因为HashMap的设计是给单线程使用的,所以如果取到 null(空) 值,我们可以通过HashMap的 containsKey(key)方 法来区分这个 null(空) 值到底是插入值是 null(空),还是本就没有才返回的 null(空) 值。
Reentrant lock 和 synchronized的异同
相同:
那么可重入锁(Reentrant lock)
是什么意思?简单地说,每个锁都有一个与之关联的计数器,如果线程再次获取它,计数器就加1,然后需要释放两次才能真正释放该锁。
这和synchronized的语义是相似的。如果线程通过已持有的monitor进入了另一个同步块(例如在一个同步方法中进入了另一个同步方法),该线程被允许执行,但是在线程退出第二个同步块时monitor不会被释放,只有继续退出第一个同步块后,才能真正的释放monitor。
差异:
synchronized 竞争锁时会一直等待;ReentrantLock 可以尝试获取锁,并得到获取结果 synchronized 获取锁无法设置超时;ReentrantLock 可以设置获取锁的超时时间 synchronized 无法实现公平锁;ReentrantLock 可以满足公平锁,即先等待先获取到锁 synchronized 控制等待和唤醒需要结合加锁对象的 wait() 和 notify()、notifyAll(); ReentrantLock 控制等待和唤醒需要结合 Condition(可以有多个条件变量与一个锁关联) 的 await() 和 signal()、signalAll() 方法 synchronized 是 JVM 层面实现的;ReentrantLock 是 JDK 代码层面实现 synchronized 在加锁代码块执行完或者出现异常,自动释放锁;ReentrantLock 不会自动释放锁,需要在 finally{} 代码块显示释放
转载自:https://juejin.cn/post/7269983642155532347