《Effective Java》第十一章
第七十八条:同步访问共享的可变数据
一、重要性
- 数据一致性:确保多个线程对共享可变数据的访问是同步的,以防止数据出现不一致的情况。如果不同线程在没有同步的情况下同时修改共享数据,可能会导致数据损坏或产生不可预测的结果。
- 避免竞争条件:同步机制可以避免竞争条件的发生。竞争条件是指多个线程同时访问和修改共享数据,且结果取决于线程执行的顺序,这可能导致错误的行为。
- 线程安全:通过同步访问共享可变数据,可以使代码在多线程环境下是线程安全的。线程安全的代码可以正确地处理并发访问,而不会出现数据损坏或错误的结果。
二、实现方法
- 使用同步方法或同步块:在 Java 中,可以使用
synchronized
关键字来实现同步。可以将方法声明为synchronized
,或者在代码块中使用synchronized
语句来指定需要同步的对象。 - 选择合适的同步对象:同步对象应该是所有需要同步访问的线程都可见的。通常,可以选择共享数据所在的对象作为同步对象,或者创建一个专门的同步对象来保护共享数据。
- 避免过度同步:过度同步会降低程序的性能,因为同步会引入一定的开销。只在必要的时候进行同步,避免不必要的同步操作。
三、注意事项
- 死锁风险:在使用同步时,要注意避免死锁的发生。死锁是指两个或多个线程相互等待对方释放资源,导致程序无法继续执行的情况。要仔细设计同步代码,避免出现循环等待的情况。
- 性能影响:同步会带来一定的性能开销,特别是在高并发的情况下。要评估同步对程序性能的影响,并在必要时进行优化。
- 可维护性:同步代码可能会比较复杂,难以理解和维护。要使用清晰的代码结构和注释,以便其他开发人员能够理解同步的逻辑。
四、总结
同步访问共享的可变数据是确保多线程程序正确性和线程安全的关键。通过使用同步方法或同步块,可以防止数据不一致和竞争条件的发生。在实现同步时,要选择合适的同步对象,避免过度同步,并注意死锁风险和性能影响。同时,要保持代码的可维护性,以便在后续的开发和维护中能够正确地处理同步问题。
第七十九条:避免过度同步
一、过度同步的弊端
- 性能损失:过度同步会引入额外的开销,降低程序的性能。同步操作通常涉及到获取锁、释放锁以及上下文切换等,这些操作会消耗时间和资源。
- 可伸缩性问题:在高并发环境下,过度同步可能会导致线程竞争激烈,从而限制系统的可伸缩性。过多的线程等待锁会降低系统的吞吐量和响应速度。
- 死锁风险增加:过度同步可能会增加死锁的发生概率。当多个线程相互等待对方持有的锁时,就会发生死锁,导致程序无法继续执行。
二、避免过度同步的方法
- 最小化同步范围:只在必要的代码段进行同步,尽量缩小同步块的范围。这样可以减少同步带来的开销,提高程序的性能。
- 使用合适的同步机制:根据具体情况选择合适的同步机制。例如,可以使用
java.util.concurrent
包中的并发工具类,如ReentrantLock
、Semaphore
等,它们提供了更灵活的同步控制。 - 避免不必要的同步:在一些情况下,可能不需要进行同步。例如,只读操作通常不需要同步,因为它们不会修改共享数据。此外,如果多个线程之间不存在数据竞争,也可以避免同步。
- 考虑线程安全的替代方案:有时候,可以通过使用线程安全的数据结构或算法来避免同步。例如,使用
ConcurrentHashMap
代替HashMap
可以在多线程环境下无需显式同步。
三、注意事项
- 确保线程安全:在避免过度同步的同时,要确保程序的线程安全。不能为了提高性能而牺牲线程安全,否则可能会导致数据不一致或其他错误。
- 测试和验证:在进行优化时,要进行充分的测试和验证,确保程序在不同的并发场景下都能正确运行。特别是对于一些复杂的并发程序,可能需要进行压力测试和性能测试。
- 理解同步的本质:要深入理解同步的本质和作用,以便在需要的时候正确地使用同步。同步是为了保证数据的一致性和线程安全,但过度同步可能会带来负面影响。
四、总结
避免过度同步是提高并发程序性能和可伸缩性的重要原则。通过最小化同步范围、选择合适的同步机制、避免不必要的同步以及考虑线程安全的替代方案,可以在保证线程安全的前提下减少同步带来的开销。在进行优化时,要注意确保程序的正确性,并进行充分的测试和验证。
第八十条:executor、task 和 stream 优先于线程
一、传统线程的问题
- 管理复杂性:直接使用线程进行并发编程时,需要手动管理线程的创建、启动、停止等操作,这会增加代码的复杂性和出错的可能性。
- 资源管理困难:线程的创建和销毁会消耗系统资源,如果管理不当,可能会导致资源耗尽。同时,过多的线程也会增加上下文切换的开销,影响性能。
- 缺乏高级功能:传统线程编程缺乏一些高级功能,如任务调度、线程池管理、异步执行等。
二、executor、task 和 stream 的优势
- 简化并发编程:
Executor
框架提供了一种高级的并发编程模型,它将任务的提交和执行分离,使得开发者无需直接管理线程。通过ExecutorService
可以方便地提交任务,并由框架自动管理线程的创建和调度。 - 高效的资源管理:
Executor
框架通常会使用线程池来管理线程,避免了频繁创建和销毁线程的开销。线程池可以根据需要自动调整线程数量,提高资源利用率和性能。 - 丰富的功能:
Executor
框架提供了多种任务执行方式,如异步执行、定时执行、周期性执行等。同时,Stream
API 也提供了一种简洁的方式来处理集合数据的并行操作,使得并发编程更加高效和易于理解。 - 更好的可维护性:使用
Executor
、task
和stream
可以使代码更加模块化和可维护。任务的定义和执行可以独立于具体的线程管理,使得代码更容易测试和调试。
三、使用建议
- 选择合适的
Executor
:根据任务的特点和需求,选择合适的ExecutorService
实现,如ThreadPoolExecutor
、ScheduledThreadPoolExecutor
等。可以调整线程池的参数,如核心线程数、最大线程数、队列大小等,以满足不同的性能要求。 - 定义清晰的任务:将任务定义为
Runnable
或Callable
接口的实现,确保任务的独立性和可重用性。任务的执行逻辑应该尽量简单,避免复杂的同步和资源共享。 - 合理使用
Stream
:在处理集合数据时,可以考虑使用Stream
API 进行并行操作。但要注意数据的大小和并行度的选择,避免过度并行导致性能下降。 - 监控和调整:在实际应用中,要监控
Executor
的运行状态,如线程池的大小、任务队列的长度等。根据实际情况进行调整,以确保系统的性能和稳定性。
四、总结
在并发编程中,优先使用executor
、task
和stream
可以简化代码、提高资源利用率和性能,同时也增强了代码的可维护性。通过合理选择Executor
、定义清晰的任务和合理使用Stream
,可以更加高效地进行并发编程。但在使用过程中,也需要注意监控和调整,以确保系统的稳定性和性能。
第八十一条:并发工具优先于 wait 和 notify
一、wait 和 notify 的问题
- 易出错:使用
wait
和notify
进行线程间通信容易出现错误,例如忘记在同步块中调用wait
、错误地判断条件导致无限等待、在不合适的地方调用notify
等。这些错误可能导致程序出现死锁、性能问题或不正确的行为。 - 缺乏灵活性:
wait
和notify
的使用方式相对固定,缺乏一些高级的功能和灵活性。例如,难以实现复杂的线程间协作模式、难以控制线程的唤醒顺序等。 - 可读性差:使用
wait
和notify
的代码通常比较复杂,难以理解和维护。这使得代码的可读性降低,增加了开发和调试的难度。
二、并发工具的优势
- 更安全:并发工具类如
java.util.concurrent
包中的各种类提供了更安全的线程间通信机制。它们通常会自动处理一些常见的错误情况,例如在合适的时候进行锁的释放和获取,避免死锁的发生。 - 更灵活:并发工具提供了更多的功能和灵活性,可以满足不同的线程间协作需求。例如,
CountDownLatch
可以用于等待多个线程完成任务后再继续执行,Semaphore
可以用于控制同时访问资源的线程数量等。 - 可读性高:使用并发工具的代码通常更加简洁和易于理解。这些工具类通常有清晰的接口和文档,使得开发人员能够更容易地理解和使用它们。
- 性能更好:在一些情况下,并发工具可能会提供更好的性能。它们通常经过了优化,可以更高效地处理线程间通信和同步问题。
三、使用建议
- 选择合适的并发工具:根据具体的需求选择合适的并发工具类。例如,如果需要等待多个事件的发生,可以使用
CountDownLatch
;如果需要控制资源的访问数量,可以使用Semaphore
等。 - 理解工具的原理:在使用并发工具之前,要理解它们的工作原理和使用方法。阅读相关的文档和示例代码,确保正确地使用这些工具。
- 避免过度使用:虽然并发工具很方便,但也不要过度使用。在一些简单的情况下,直接使用同步块可能更加高效和简洁。
- 测试和调试:在使用并发工具的代码中,要进行充分的测试和调试,确保线程间通信的正确性和性能。可以使用一些并发测试工具来帮助发现潜在的问题。
四、总结
在并发编程中,优先使用并发工具类而不是wait
和notify
可以提高代码的安全性、灵活性、可读性和性能。选择合适的并发工具,并正确地使用它们,可以更好地处理线程间通信和同步问题,提高程序的可靠性和可维护性。
第八十二条:线程安全性的文档化
一、重要性
- 提高代码可维护性:明确记录代码的线程安全性可以让后续的开发人员更好地理解代码在多线程环境下的行为,减少错误的发生,提高代码的可维护性。
- 便于团队协作:在团队开发中,文档化线程安全性可以确保团队成员对代码的线程安全特性有一致的理解,避免因误解而导致的问题,促进团队协作。
- 帮助用户正确使用:如果代码是一个库或框架,文档化线程安全性可以帮助用户正确地使用代码,避免在多线程环境下出现意外的错误。
二、文档内容要点
- 线程安全级别:明确说明代码的线程安全级别,例如是完全线程安全、线程兼容还是线程不安全。完全线程安全的代码可以在多线程环境下被任意访问而不会出现问题;线程兼容的代码在特定的使用模式下是线程安全的;线程不安全的代码在多线程环境下可能会出现问题。
- 同步策略:描述代码使用的同步策略,例如使用了哪些锁、同步块或者并发工具类。这可以帮助开发人员理解代码是如何保证线程安全的。
- 可变性:说明代码中的可变状态是如何管理的。如果代码中有可变的共享数据,需要明确说明如何保证对这些数据的并发访问是安全的。
- 线程限制:如果代码在某些情况下存在线程限制,例如只能在特定的线程中执行某些操作,需要在文档中明确说明这些限制。
三、文档方法
- JavaDoc 注释:使用 JavaDoc 注释来记录代码的线程安全性。在方法和类的注释中,可以使用特定的标签来描述线程安全特性,例如
@ThreadSafe
、@NotThreadSafe
等。 - 文档页面:除了 JavaDoc 注释,还可以在项目的文档页面中提供更详细的线程安全性说明。可以包括一些示例代码、使用注意事项等。
- 代码审查:在代码审查过程中,确保线程安全性的文档得到了正确的更新和维护。如果代码的线程安全特性发生了变化,需要及时更新文档。
四、注意事项
- 准确性:文档化的线程安全性必须准确反映代码的实际情况。如果文档中的描述与代码的实际行为不符,可能会导致开发人员做出错误的假设,从而引发问题。
- 及时性:随着代码的演进,线程安全性可能会发生变化。需要及时更新文档,以确保文档与代码保持一致。
- 简洁明了:文档应该简洁明了,避免过于复杂的描述。使用清晰的语言和示例来说明代码的线程安全特性,让开发人员能够快速理解。
五、总结
线程安全性的文档化是编写高质量并发代码的重要环节。通过明确记录代码的线程安全级别、同步策略、可变性和线程限制等信息,可以提高代码的可维护性、便于团队协作,并帮助用户正确使用代码。在文档化过程中,要确保准确性、及时性和简洁明了,以提高文档的质量和实用性。
第八十三条:慎用延迟初始化
一、延迟初始化的风险
- 复杂性增加:延迟初始化会使代码的逻辑变得更加复杂,因为需要考虑何时进行初始化以及在多线程环境下的同步问题。这可能导致代码难以理解和维护。
- 潜在的性能问题:虽然延迟初始化可以在某些情况下节省资源,但如果初始化过程本身很耗时,或者在高并发环境下频繁触发初始化,可能会导致性能下降。
- 线程安全问题:在多线程环境中,延迟初始化需要仔细处理同步,以确保初始化只进行一次且不会出现数据竞争。如果处理不当,可能会导致错误的结果或死锁。
二、何时谨慎使用延迟初始化
- 资源敏感场景:当资源的创建非常昂贵,并且在程序的整个生命周期中可能并不总是需要时,可以考虑延迟初始化。但要仔细评估资源的使用模式和性能影响。
- 罕见情况:如果某个对象的创建只在非常罕见的情况下才需要,可以使用延迟初始化来避免不必要的资源消耗。但同样要注意处理好线程安全问题。
三、正确使用延迟初始化的方法
- 使用同步机制:可以使用
synchronized
关键字或其他同步工具来确保在多线程环境下延迟初始化的正确性。但要注意同步带来的性能开销。 - 双重检查锁定:一种常见的延迟初始化技术是双重检查锁定,但要注意这种技术在某些 Java 版本中可能存在问题,需要谨慎使用。
- 依赖注入:在一些情况下,可以使用依赖注入框架来管理对象的创建和初始化,避免手动进行延迟初始化。
四、注意事项
- 测试充分性:由于延迟初始化涉及到多线程和复杂的逻辑,需要进行充分的测试,确保在各种情况下都能正确工作。
- 文档说明:如果代码中使用了延迟初始化,应该在文档中清楚地说明初始化的时机和线程安全的处理方式,以便其他开发人员理解。
- 性能评估:在决定使用延迟初始化时,要进行性能评估,确保其带来的好处大于潜在的风险和性能开销。
五、总结
延迟初始化可以在某些情况下节省资源,但也带来了复杂性、性能问题和线程安全风险。在使用延迟初始化时,要谨慎评估其必要性,选择正确的实现方法,并进行充分的测试和性能评估。同时,要注意文档说明,以便其他开发人员能够理解和正确使用代码。
第八十四条:不要依赖于线程调度器
public class Example {
private static volatile boolean flag = false;
public static void main(String[] args) {
Thread t1 = new Thread(() -> {
while (!flag) {
// 等待flag被设置
}
System.out.println("Thread 1 finished");
});
Thread t2 = new Thread(() -> {
flag = true;
System.out.println("Thread 2 finished");
});
t1.start();
t2.start();
}
}
一、依赖线程调度器的风险
- 不可预测性:线程调度器的行为是不确定的,它可能会根据操作系统、JVM 实现以及当前系统负载等因素而变化。依赖线程调度器可能导致程序的行为不可预测,难以调试和维护。
- 性能问题:过度依赖线程调度器可能会导致性能下降。例如,如果程序频繁地等待特定的线程调度顺序,可能会浪费大量的时间在等待上,而不是执行有用的工作。
- 死锁和活锁:依赖线程调度器可能会增加死锁和活锁的风险。如果多个线程相互等待对方释放资源,或者在等待特定的线程调度顺序时陷入无限循环,就可能发生死锁或活锁。
二、正确的并发编程方法
- 使用同步机制:通过使用同步机制,如锁、信号量和条件变量等,可以确保线程之间的正确协作,而不依赖于线程调度器的特定行为。
- 避免忙等待:不要使用忙等待(不断地循环检查某个条件)来等待其他线程的完成。这会浪费 CPU 资源,并且可能会导致性能问题。可以使用等待/通知机制或其他同步工具来避免忙等待。
- 设计可预测的代码:在设计并发程序时,应该尽量使程序的行为可预测。避免依赖于特定的线程调度顺序,而是通过合理的同步和协作机制来确保程序的正确性。
- 测试和验证:对并发程序进行充分的测试和验证,以确保在不同的线程调度情况下都能正确工作。可以使用多线程测试框架来模拟不同的线程调度场景,发现潜在的问题。
三、注意事项
- 理解线程调度器的工作原理:虽然不应该依赖线程调度器,但了解线程调度器的工作原理可以帮助我们更好地理解并发程序的行为。这有助于我们在出现问题时进行调试和优化。
- 考虑性能和可扩展性:在设计并发程序时,要考虑性能和可扩展性。避免使用过于复杂的同步机制,以免影响性能。同时,要确保程序能够在不同的硬件和软件环境下良好地运行。
- 遵循最佳实践:遵循并发编程的最佳实践,如避免共享可变状态、使用线程安全的数据结构等。这些最佳实践可以帮助我们编写更可靠、更高效的并发程序。
四、总结
不要依赖于线程调度器是编写可靠并发程序的重要原则。线程调度器的行为是不确定的,依赖它可能会导致程序的行为不可预测、性能下降以及增加死锁和活锁的风险。通过使用同步机制、避免忙等待、设计可预测的代码以及进行充分的测试和验证,我们可以编写更可靠、更高效的并发程序。同时,了解线程调度器的工作原理和遵循最佳实践也有助于我们更好地理解和编写并发程序。