11. Lock和synchronized相比有什么优势?
在Java并发编程中,Lock(显式锁)是JDK 1.5引入的,旨在弥补内置锁synchronized的不足。以下是Lock相比于synchronized的核心优势:
11.1 可中断的锁获取(响应中断)
- synchronized:如果一个线程在等待获取锁时被中断,它不会响应中断,还是会继续阻塞等待,直到拿到锁。
- Lock:提供了
lockInterruptibly()方法。如果线程在等待锁的过程中被其他线程调用了interrupt(),它会抛出InterruptedException并停止等待。*这在避免死锁或取消耗时任务时非常有用。
11.2 非阻塞的锁获取(尝试获取锁)
- synchronized:如果锁被其他线程占用,当前线程只能无条件阻塞等待,无法做其他事情。
- Lock:提供了
tryLock()方法。它会尝试获取锁,如果锁空闲则获取成功;如果锁被占用,则立即返回false,不会阻塞。另外还提供了tryLock(long time, TimeUnit unit),在指定时间内尝试获取锁,超时则放弃。这可以有效地避免线程死锁或长时间卡死。
11.3 支持公平锁
- synchronized:是一种非公平锁。当锁释放时,任何等待的线程都有机会抢到锁,无法保证等待时间最长的线程先获取锁,这可能导致“线程饥饿”。
- Lock:
ReentrantLock等实现类支持公平锁机制。在创建时传入true(new ReentrantLock(true)),它会按照线程请求锁的先后顺序(FIFO队列)来分配锁,保证先到先得,避免饥饿。
11.4 绑定多个条件变量
- synchronized:只能与一个内置的等待队列绑定,即通过
wait()、notify()、notifyAll()来实现线程通信。当有多个条件时,往往只能使用notifyAll()唤醒所有线程,效率低下(“惊群效应”)。 - Lock:提供了
Condition接口,一个Lock可以绑定多个Condition。例如在生产者-消费者模型中,可以创建notFull和notEmpty两个条件,分别唤醒生产者或消费者,实现精准唤醒,大幅提升并发性能。
11.5 读写分离锁
- synchronized:只有独占锁,同一时刻只能有一个线程(不管是读还是写)访问临界区。在读多写少的场景下,性能极差。
- Lock:提供了
ReentrantReadWriteLock(读写锁)。它允许多个读线程同时访问,但写线程访问时,所有读线程和其他写线程都会被阻塞。这极大地优化了读多写少场景的并发度。
11.6 锁状态的监控与诊断
-
synchronized:无法在代码层面直接获取锁的状态(如是否被占用、等待队列长度等),排查死锁时通常只能依赖JVM工具(如
jstack)。 -
Lock:提供了丰富的API来监控锁的状态,例如:
isLocked():判断锁是否被占用。getQueueLength():获取等待锁的线程数量。isHeldByCurrentThread():判断当前线程是否持有该锁。getHoldCount():查询当前线程持有该锁的次数。
不过synchronized也并非一无是处,它使用简单,不需要手动释放锁,从JDK 1.6开始,JVM对synchronized做了大量优化(偏向锁、轻量级锁、自旋锁等),在多数无明显竞争的场景下,性能并不逊色于Lock,在不需要Lock的高级特性时可以优先使用它。
12. java中基本类型的包装类以及String类型,是线程安全的么?
12.1 它们是不可变类,所以对象本身线程安全
基本类型的包装类(Integer、Long、Double 等)和 String 都是“不可变类”,因此它们的对象本身是线程安全的。
因为:
- 对象状态(包装类里的
value)初始化后就不会变; - 多线程读同一个实例,看到的永远是一致的值,不会出现“写了一半”的状态;
- 没有任何方法可以修改对象内部状态,所以没有竞态条件。
从实现上看,这些包装类都满足典型不可变类的要求:
- 类声明为
final,不能被子类化破坏不可变性; - 字段是
private final; - 没有 setter 方法,所有“修改”操作返回新对象。
所以:对同一个 Integer/Long 等实例,多线程只读不写时,是线程安全的,无需加锁。
12.2 但“引用可变”时,仍可能线程不安全
线程安全看的是“共享可变状态”。包装类对象本身不可变,但**引用它的变量可以是共享可变的。那引用这一层需要同步或保证可见性(volatile / AtomicReference 等)
class Holder {
// 共享可变引用
private Integer value = 0;
public void setValue(int v) {
value = v; // 写引用
}
public int getValue() {
return value; // 读引用(自动拆箱)
}
}
比如上面这段代码的问题不是 Integer 对象本身可变,而是 value 这个引用可变:
- 线程 A 调
setValue(1); - 线程 B 调
getValue(); - 如果没有同步,
value的可见性/原子性都可能出问题。
12.3 和 AtomicInteger / AtomicLong 等原子类的区别
- 原子类(AtomicInteger 等) :可变,但通过 CAS + volatile 保证了对同一个实例的复合操作(如
incrementAndGet())是线程安全的,专门用来做共享计数器等。 - 需要线程安全的计数器/累加器,应该用
AtomicInteger/AtomicLong,而不是用Integer/Long+ 自己加锁。
13. 我们都知道死锁,那什么是活锁,如何检测和解决?
活锁指的是任务或执行者没有被阻塞,但由于某些条件始终无法满足,导致其不断重复尝试、失败、再尝试、再失败的过程。实体(线程/进程)的状态在不断变化(所谓的“活”),但任务却始终无法推进。
活锁的场景常常源于不合理的重试策略或过度协调,导致线程间互相“礼让”却始终无法达成共识。好比两人在一条窄走廊相遇,他们同时向左侧避让,发现又挡住对方,于是又同时向右侧避让,结果依然如此,于是不断重复这个动作,始终无法通过。
检测活锁通常比检测死锁更困难,因为线程一直在活动。以下是一些观察和诊断的方法:
- 观察CPU利用率:如果CPU利用率异常高(持续接近100%),但系统吞吐量或响应速度却很低,这可能是活锁的一个强烈信号。
- 日志分析:在关键的冲突检测和重试点添加日志。如果日志中频繁出现大量的“冲突检测”、“重试”、“回退”等消息,且模式非常规律,那可能就是活锁。
- 调试工具:使用线程调试工具(如JStack、VisualVM等)查看线程的调用栈。在活锁中,线程栈通常显示它们一直处于某个重试循环或忙等状态,而不是处于
WAITING或BLOCKED状态。 - 模拟与重现:通过压力测试工具模拟高并发场景,尝试重现问题。
解决活锁的方法主要是:
- 引入随机性(Randomization)
这是最有效和最常用的方法。当冲突发生时,线程在重试前等待一个随机的时间,而不是固定的时间。这能打破“同步”的节奏,极大降低再次发生冲突的概率。 - 设置最大重试次数(Max Retry Count)
为重试设置一个上限。如果多次重试后仍然失败,则放弃或采用其他策略(如报告错误、回退到较低级别的操作等)。这可以防止无限循环,但需要注意业务逻辑的健壮性。
14. java 并发编程中,有哪些情况不能使用if,而要使用while,为什么?
在Java并发编程中,当使用wait()/notify()或Condition.await()/signal()机制等待特定条件时,必须用while循环而非if语句检查条件,这是并发安全的黄金准则。
核心原因在于:if仅做单次条件检查,无法应对虚假唤醒(Spurious Wakeup)和多线程竞争导致的条件状态突变,而while循环能强制线程在被唤醒后重新验证条件,确保操作的安全性。
如果你使用 if:
// 错误示范
synchronized (lock) {
if (条件不满足) { // 假设条件不满足进入等待
lock.wait();
}
// 虚假唤醒发生在这里,线程直接往下执行,但此时条件依然不满足!
// 执行后续操作... (可能导致程序出错或数据破坏)
}
使用 while:
// 正确示范
synchronized (lock) {
while (条件不满足) {
lock.wait(); // 如果虚假唤醒,条件依然不满足,循环回去继续睡
}
// 只有条件真正满足,才会执行到这里
// 执行后续操作...
}
15. Log4j 的AsyncLogger是如何实现的异步日志,适合什么场景?
简单理解:
- 同步:业务线程 → 直接写磁盘/网络 → 返回
- 异步:业务线程 → 把日志丢进数组 → 立即返回;后台线程从数组取日志 → 真正 I/O 写入
AsyncLogger的底层基于“生产者-消费者”模型,具体流程如下:
- 生产者(业务线程) :当你的代码调用
logger.info()时,业务线程并不会直接执行耗时的磁盘 I/O 操作。它只是将日志信息封装成一个LogEvent对象,然后迅速将其丢入内存中的一个 RingBuffer(环形缓冲区),随后立刻返回继续执行业务逻辑。 - RingBuffer(环形缓冲区) :这是一个预分配好大小的固定容量数组。得益于无锁设计和 CPU 缓存行填充技术,多线程向其中写入数据的速度极快,且几乎不会发生线程阻塞。
- 消费者(后台线程) :Log4j2 会在后台启动一个或多个独立的线程,持续从 RingBuffer 中取出
LogEvent,并交给 Appender 完成真正的格式化与落盘(写入文件或网络)操作。
这种设计将繁重的 I/O 操作从关键的业务线程中剥离出来,极大地降低了系统的响应延迟,提升了整体吞吐量。
当 RingBuffer 满时,行为由 AsyncQueueFullPolicy 决定:
- DefaultAsyncQueueFullPolicy(默认):
队列满时阻塞业务线程,直到有空间;相当于退化为同步日志,但不会丢日志。 - DiscardingAsyncQueueFullPolicy:
队列满(或剩余容量低于阈值)时,按级别丢弃新事件,例如只保留 ERROR 及以上,丢弃 INFO/DEBUG 等。
适合异步的典型 Web 场景
1. 高并发接口 / 网关
典型特征:
- QPS 很高,每秒成千上万甚至更多请求
- 接口对 RT(响应时间)非常敏感
- 日志量巨大,大量 INFO/DEBUG 日志
2. 微服务 / 分布式系统大量 INFO/DEBUG 日志
- 每个请求要打很多上下文日志(traceId、userId、参数等)
- 大量服务实例,总日志量很大
- 多数日志是“排查用”,不是“计费用”
3. 低延迟要求,但对少量日志丢失可接受
例如:
- 实时报价、游戏服务器、流式计算任务
- 更关心“主流程快”,哪怕偶尔丢一条 DEBUG/INFO 也没关系
4. 非审计 / 计费 / 安全日志
官方明确建议:
如果日志是业务逻辑的一部分,比如把 Log4j 当审计日志框架用,建议这些审计消息同步记录。
Web 项目中,一个比较稳妥的做法:
- 审计/关键日志:用同步 Logger(或同步 Appender)
- 普通业务日志:用 AsyncLogger / AsyncRoot
16. Log4j 的AsyncLogger实现的异步日志,什么场景下可能会丢日志?
虽然异步日志性能强悍,但在以下几种特定场景中,确实存在日志丢失的风险:
1. JVM 异常终止或未优雅关闭(最常见的原因)
由于日志事件是暂存在内存的 RingBuffer 中的,如果应用在日志被后台线程消费并落盘之前突然退出,缓冲区里的日志就会全部丢失。典型场景包括:
- 强制杀掉进程:例如在 Linux 环境下使用
kill -9强杀,或者系统发生 OOM(内存溢出)导致进程被操作系统强制销毁。 - 应用快速退出:在单元测试或脚本中调用了
System.exit(),或者 Spring Boot 应用接收到SIGTERM信号后秒级退出,没有给后台日志线程留出足够的刷新(flush)时间。 - 关闭钩子被禁用:如果在
log4j2.xml配置中显式设置了shutdownHook="disable",JVM 退出时将不会触发 Log4j2 的清理和刷盘机制。
2. 突发高并发导致 RingBuffer 溢出
RingBuffer 的容量是有限的(默认大小约为 256KB,可容纳约 2^16 个事件)。如果瞬间产生的日志量极大(例如突发 5k+ QPS),填满了整个缓冲区,而后台消费线程来不及处理,就会触发“背压”策略。
- 如果配置的等待策略(WaitStrategy)是
Timeout且超时时间设置过短,或者采用了丢弃策略,新产生的日志事件就会被静默丢弃,且通常没有任何错误提示。
3. 获取代码位置信息(includeLocation)引发阻塞
如果在异步日志配置中开启了 includeLocation="true"(用于打印日志所在的类名、方法名和行号),Log4j2 每次打印日志时都需要去抓取当前的线程堆栈信息。
- 这是一个非常消耗 CPU 的操作。在高并发场景下,获取堆栈信息的耗时会导致生产者线程向 RingBuffer 写入的速度变慢甚至阻塞,进而间接导致 RingBuffer 更容易被填满并触发上述的溢出丢弃风险。
如何规避日志丢失?
- 确保优雅关闭:保持
shutdownHook="enable"(默认即为开启),让应用在退出前有机会把 RingBuffer 中剩余的日志刷入磁盘。 - 合理配置缓冲区与策略:根据业务量适当调大 RingBuffer 的大小,并选择合适的等待策略(如
Blocking或适当延长Timeout时间),避免粗暴丢弃。 - 关闭非必要的位置信息:在生产环境的高并发异步日志中,建议将
includeLocation设置为false,以换取极致的性能和稳定性。
更多精彩内容,可关注作者公众号:Coder看世界