文章目录
73. 怎么实现一个线程安全的计数器 ?
实现一个线程安全的计数器,主要的方法是通过同步机制来确保在多个线程同时访问计数器时,其值能正确地更新。Java 中有几种常见的实现方式:
1. 使用 synchronized
关键字
你可以将计数器类中的增加方法(如 increment()
)用 synchronized
关键字修饰,以确保一次只有一个线程能执行这个方法。
public class SafeCounter {
private int count = 0;
public synchronized void increment() {
count++;
}
public synchronized int getCount() {
return count;
}
}
注意,虽然 increment()
和 getCount()
方法都被声明为 synchronized
,但在某些情况下可能只需要对 increment()
进行同步,因为 getCount()
只是返回当前值,不涉及修改。然而,为了保持一致性,有时还是需要对读操作也进行同步,尤其是当读操作的结果依赖于写操作的结果时。
2. 使用 AtomicInteger
Java 并发包 java.util.concurrent.atomic
提供了原子变量类,如 AtomicInteger
,这些类利用底层的硬件特性(如 CAS,Compare-And-Swap)来提供无锁的线程安全操作。
import java.util.concurrent.atomic.AtomicInteger;
public class SafeCounter {
private AtomicInteger count = new AtomicInteger(0);
public void increment() {
count.incrementAndGet();
}
public int getCount() {
return count.get();
}
}
AtomicInteger
的 incrementAndGet()
方法会原子地增加当前值并返回新的值,这避免了使用 synchronized
关键字可能带来的性能开销。
3. 使用 volatile
关键字(不推荐单独使用)
虽然 volatile
关键字可以确保变量的可见性,但它并不能保证操作的原子性。因此,仅使用 volatile
修饰计数器变量通常不是线程安全的,因为 count++
操作(包括读取、增加、写入)并不是原子的。
然而,如果你仅需要保证变量的可见性(例如,在一个线程中更新变量,在另一个线程中读取这个变量的最新值),并且不需要执行复合操作(如自增),那么 volatile
是有用的。
结论
对于实现线程安全的计数器,推荐使用 AtomicInteger
,因为它既简单又高效。如果需要更复杂的同步逻辑,则可以考虑使用 synchronized
关键字或其他并发工具。
74. 编写Java代码实现LRU算法 ?
LRU(Least Recently Used)算法是一种常用的页面置换算法,用于管理缓存中的数据,通过淘汰最长时间未被使用的数据项来保持缓存的有效性和性能。在Java中实现LRU算法,我们可以使用LinkedHashMap
这个数据结构,因为它内部维护了一个双向链表来记录元素的插入顺序或访问顺序。通过设置accessOrder
为true
,我们可以使LinkedHashMap
按照访问顺序来排序,从而方便实现LRU缓存。
下面是一个简单的LRU缓存的Java实现:
import java.util.LinkedHashMap;
import java.util.Map;
public class LRUCache<K, V> extends LinkedHashMap<K, V> {
private final int capacity;
public LRUCache(int capacity) {
// true 表示按访问顺序排序,最近访问的放在尾部,最久未访问的放在头部
super(capacity, 0.75f, true);
this.capacity = capacity;
}
@Override
protected boolean removeEldestEntry(Map.Entry<K, V> eldest) {
// 当map中的元素个数超过capacity时,移除最久未访问的数据
return size() > capacity;
}
public V get(Object key) {
return super.getOrDefault(key, null);
}
public void put(K key, V value) {
super.put(key, value);
}
public static void main(String[] args) {
LRUCache<Integer, String> cache = new LRUCache<>(3);
cache.put(1, "a");
cache.put(2, "b");
cache.put(3, "c");
System.out.println(cache); // 输出 {1=a, 2=b, 3=c}
cache.get(1); // 访问键为1的数据
cache.put(4, "d"); // 添加新数据,淘汰最久未访问的数据
System.out.println(cache); // 输出 {2=b, 3=c, 4=d},因为1是最近最少使用的,所以被移除
}
}
这个LRUCache
类继承自LinkedHashMap
,并重写了removeEldestEntry
方法,该方法会在每次添加新元素到map时调用,并决定是否移除最老(最久未访问)的元素。通过设置accessOrder
为true
,LinkedHashMap
会在每次访问元素时更新元素的顺序,使得最近访问的元素总是在链表的尾部,而最久未访问的元素则位于链表的头部。
需要注意的是,LinkedHashMap
的构造函数接受三个参数:初始容量、加载因子和accessOrder
。在上面的代码中,我们将加载因子设置为0.75f
(默认值),并将accessOrder
设置为true
以启用按访问顺序排序。
此外,在main
方法中展示了如何使用LRUCache
,包括添加元素和访问元素,并观察在缓存容量达到上限时,最久未访问的元素如何被自动移除。
75. 如何停止线程运行?
停止线程运行的方法在Java等编程语言中主要有以下几种,这些方法各有特点和适用场景:
1. 使用退出标志
这是一种使线程正常退出的方法。通过设置一个退出标志(如boolean类型的变量),并在线程的run方法中通过循环检查这个标志的值来控制线程的执行。当需要停止线程时,只需将标志设置为指示退出的值(如true),线程在执行到检查点时就会退出循环,从而结束线程的执行。
示例代码:
public class ThreadFlag extends Thread {
public volatile boolean exit = false;
public void run() {
while (!exit) {
// 线程执行的任务
}
}
public static void main(String[] args) throws Exception {
ThreadFlag thread = new ThreadFlag();
thread.start();
// 主线程延迟一段时间
Thread.sleep(5000);
// 终止线程
thread.exit = true;
thread.join(); // 等待线程结束
System.out.println("线程退出!");
}
}
2. 使用interrupt方法
interrupt
方法是中断线程的一种方式,但它并不会立即停止线程的执行,而是通知线程应该中断了。线程需要定期检查自己是否被中断,并据此做出响应。如果线程当前处于阻塞状态(如调用了sleep
、wait
等方法),interrupt
方法会抛出一个InterruptedException
异常,线程可以捕获这个异常并退出。
示例代码(阻塞状态中的中断):
public class ThreadInterrupt extends Thread {
public void run() {
try {
Thread.sleep(50000); // 延迟50秒
} catch (InterruptedException e) {
System.out.println(e.getMessage());
// 可以在这里处理中断逻辑,比如退出循环
}
}
public static void main(String[] args) throws Exception {
Thread thread = new ThreadInterrupt();
thread.start();
// 假设在50秒内想要中断线程
System.out.println("在50秒之内按任意键中断线程!");
System.in.read(); // 等待用户输入
thread.interrupt();
thread.join();
System.out.println("线程已经退出!");
}
}
非阻塞状态中的中断:
在这种情况下,线程应该使用isInterrupted()
方法检查中断状态,并据此决定是否退出循环或执行其他清理工作。
3. 使用stop方法(不推荐)
stop
方法是Java早期版本中提供的一种强行终止线程的方法,但由于它可能导致数据不一致和死锁等问题,已经被废弃(deprecated)。使用stop
方法可能会导致线程突然停止,从而释放锁和其他资源,这可能会对其他线程或系统状态造成不可预料的影响。
总结
在停止线程时,推荐使用退出标志或interrupt
方法,因为它们提供了更安全和可控的方式来结束线程的执行。而stop
方法由于存在安全风险,应该避免使用。在实际编程中,应根据具体需求和场景选择最适合的线程停止方法。
76. 简述普通线程与守护线程的区别 ?
普通线程(User Thread)与守护线程(Daemon Thread)在Java中是两种不同类型的线程,它们在多个方面存在显著的区别。以下是它们之间区别的详细阐述:
1. 线程特性
- 普通线程:会一直执行直到程序运行结束或线程被手动停止。它们是程序执行的主要部分,负责执行应用程序的核心业务逻辑。
- 守护线程:随着JVM(Java虚拟机)的关闭而自动结束。它们的主要作用是为其他线程提供某种服务支持,例如垃圾收集、日志记录等后台任务。守护线程的生命周期依赖于前台的普通线程,当所有普通线程结束时,守护线程也会随之终止,无论其是否执行完毕。
2. 线程优先级
- 在Java中,线程可以分为优先级较高的线程(如普通线程)和优先级较低的线程(如守护线程)。JVM会优先处理普通线程,在所有普通线程执行完成后,才会考虑关闭JVM进程,并强制终止任何仍在运行的守护线程。
3. 特殊用途
- 普通线程:用于执行主要的程序逻辑,如处理用户请求、进行数据处理等任务。它们是应用程序正常运行所必需的。
- 守护线程:通常用于执行后台支持任务,这些任务不应该阻止JVM的退出。守护线程的存在是为了辅助普通线程,提供必要的服务,但不影响程序的整体退出流程。
4. 线程启动方式
- 创建一个普通线程和创建一个守护线程的过程基本相同,但守护线程的状态需要通过调用Thread类中的setDaemon(true)方法来设置。重要的是,这个调用必须在线程启动(即调用start()方法)之前进行,否则将抛出IllegalThreadStateException异常。
5. 对JVM退出的影响
- 普通线程:JVM会等待所有非守护线程运行完成后才退出。
- 守护线程:JVM不会等待守护线程完成。当所有非守护线程结束时,即使守护线程仍在运行,JVM也会退出并终止所有守护线程。
6. 实际应用场景
- 普通线程:用于实现工作线程(Worker Thread)功能,处理应用程序的核心业务逻辑。
- 守护线程:用于执行不重要的后台任务,如垃圾回收、日志记录等。这些任务不需要在程序退出时完成,也不会影响程序的正常退出流程。
综上所述,普通线程与守护线程在Java中各有其独特的用途和特性。在编写多线程程序时,应根据具体需求和场景选择合适的线程类型以达到最佳效果。
77. 简述什么是锁顺序死锁?
锁顺序死锁是并发编程中常见的一种死锁类型,它发生在多个线程以不同的顺序尝试获取相同的锁集合时。以下是对锁顺序死锁的详细解释:
定义
锁顺序死锁是指当多个线程在访问多个资源时,每个线程都按照各自的顺序获取锁,而这些顺序在某些情况下相互冲突,导致线程相互等待对方释放锁,从而形成死锁。这种死锁特别常见于多线程环境中,当线程需要同时持有多个锁来完成某项操作时。
产生原因
锁顺序死锁的产生主要源于以下几个原因:
- 多个线程访问多个资源:在多线程环境中,线程可能需要同时访问多个资源(通常以锁的形式出现),这些资源在逻辑上可能是相互关联的。
- 锁获取顺序不一致:不同的线程在尝试获取这些锁时,可能采用了不同的顺序。当这些顺序在某些情况下相互冲突时,就可能导致死锁。
- 资源互斥性:每个锁在同一时刻只能被一个线程持有,这是锁的基本特性。当线程需要等待其他线程释放锁时,如果形成了循环等待,就会导致死锁。
示例
假设有两个线程T1和T2,以及两个锁L1和L2。线程T1首先获取锁L1,然后尝试获取锁L2;而线程T2首先获取锁L2,然后尝试获取锁L1。如果两个线程在尝试获取对方已持有的锁时都被阻塞,那么它们就陷入了锁顺序死锁。
必要条件
锁顺序死锁的发生通常需要满足死锁的四个必要条件:
- 互斥条件:资源是独占且排他的,即任意时刻一个资源只能被一个线程使用。
- 请求与保持条件:线程已经保持至少一个资源,又请求新的资源而失败,此时请求线程被阻塞,但对自己已获得的资源保持不放。
- 不可剥夺条件:线程已获得的资源在未使用完之前不能被其他线程强行剥夺,而只能由获得该资源的线程进行释放。
- 循环等待条件:若干线程之间形成一种头尾相接的循环等待资源关系。
解决方案
解决锁顺序死锁的一种有效方法是制定并严格遵守一致的锁获取顺序。即所有线程在访问多个锁时,都按照相同的顺序来获取这些锁。这样可以避免因锁获取顺序不一致而导致的死锁。另外,还可以采用锁超时、锁尝试重入等策略来降低死锁的风险。
综上所述,锁顺序死锁是并发编程中需要特别注意的一种问题。通过合理设计锁的获取顺序和采用适当的并发控制策略,可以有效地避免死锁的发生。
78. 死锁与活锁的区别,死锁与饥饿的区别?
死锁、活锁和饥饿是并发编程中常见的三种不良状态,它们在表现、原因和解决方案上都存在显著的区别。
一、死锁与活锁的区别
-
定义与表现
- 死锁:是指两个或两个以上的进程在执行过程中,由于竞争资源或者由于彼此通信而造成的一种阻塞的现象,若无外力作用,它们都将无法向前推进。死锁通常表现为多个进程相互等待对方释放资源,导致所有进程都被阻塞,无法向前执行。
- 活锁:是指任务或者执行者没有被阻塞,由于某些条件没有满足,导致一直重复尝试,失败,再尝试,再失败。活锁中的实体是在不断改变状态的,只是无法达到目的。活锁有可能在一定时间后自动解开,但也可能因为某些原因而持续存在。
-
产生原因
- 死锁:主要是由于进程之间的资源竞争和循环等待造成的。当多个进程同时访问不同的资源,并试图以不同的顺序获得这些资源时,就可能发生死锁。
- 活锁:最常见的原因是进程组的执行顺序不合理,导致某些先需要的资源被后置。此外,也可能是由于进程间的调度策略不当,使得进程在尝试获取资源时陷入了一种无休止的循环状态。
-
解决方案
- 死锁:可以通过破坏死锁的四个必要条件(互斥条件、请求和保持条件、不可剥夺条件、循环等待条件)来预防死锁。此外,还可以使用避免策略,在资源分配过程中使用某种方法防止系统进入不安全状态。如果死锁已经发生,还可以通过检测与恢复策略来解除死锁。
- 活锁:解决活锁的关键在于调整进程的执行顺序或调度策略,以避免进程陷入无休止的循环状态。此外,还可以引入随机性,如随机延迟进程的重试时间,以减少活锁的发生概率。
二、死锁与饥饿的区别
-
定义与表现
- 死锁:如上所述,是指多个进程相互等待对方释放资源而导致的阻塞现象。
- 饥饿:是指一个或多个进程因为无法获得所需的资源而无法继续执行。即使其他进程并不总是需要这些资源,也无法获得资源的进程可能会永远等待。饥饿通常表现为进程长时间无法获得所需资源,导致进程无法执行其任务。
-
产生原因
- 死锁:如上所述,主要是由于资源竞争和循环等待造成的。
- 饥饿:可能是由于资源分配不公平、优先级调度等原因造成的。例如,在优先级调度中,优先级低的进程可能一直被优先级高的进程抢占资源,导致优先级低的进程长时间无法获得执行机会。
-
解决方案
- 死锁:如上所述,可以通过破坏死锁的四个必要条件、使用避免策略或检测与恢复策略来解决。
- 饥饿:解决饥饿的关键在于优化资源分配策略,确保每个进程都有平等的机会获得资源。这可以通过使用公平锁、优先级反转技术或调整进程优先级等方法来实现。
综上所述,死锁、活锁和饥饿在定义、表现、产生原因和解决方案上都存在明显的区别。在并发编程中,需要深入理解这些概念,并采取相应的措施来避免和解决这些问题。
答案来自文心一言,仅供参考