78. 同步访问共享的可变数据
为了在线程之间进行可靠的通信,也为了互斥访问,同步是必要的。
不共享可变的数据。要么共享不可变的数据,要么压根不共享。换句话说,将可变数据限制在单线程中。
当多个线程共享可变数据的时候,每个读或写数据的线程必须执行同步。如果没有同步就无法保证一个线程所做的修改被另一个线程获知。如果只需要线程间的交互通信而不需要互斥,volatile是一种可以接受的同步形式。
这个在工作中也需要注意,在学习完多线程编程之后更好理解。
79. 避免过度同步
过度同步可能会导致死锁、降低性能,甚至不确定的行为。(切记不能循环锁对象造成死锁)
在同步区域内应该做很少的事情。获得锁、检查共享数据、转换数据、释放锁。如果执行很耗时的操作,应该放在同步之外。也就是说同步块中应该做尽可能少的事情。
80. Executor、task和stream优先于线程
如果需要用到多线程处理一些耗时的操作,建议使用现有的Executor。不能编写自己的工作队列、而且还应该尽量不使用线程Thread。
阿里规约对多线程的使用有如下建议:
(1)创建线程或线程池时请指定有意义的线程名称,便于排错(线程池需要指定线程工厂)
(2)线程资源必须通过线程池创建,不能在应用程序中显示创建
(3)线程池不允许使用 Executors 去创建,而是通过 ThreadPoolExecutor 的方式,这样的处理方式让写的同学更加明确线程池的运行规则,规避资源耗尽的风险。
说明: Executors 返回的线程池对象的弊端如下:
1) FixedThreadPool 和 SingleThreadPool: 允许的请求队列长度为 Integer.MAX_VALUE,可能会堆积大量的请求,从而导致 OOM。2) CachedThreadPool 和 ScheduledThreadPool: 允许的创建线程数量为 Integer.MAX_VALUE, 可能会创建大量的线程,从而导致 OOM
81. 并发工具优于wait和notitfy
正确地使用wait和notify比较困难,应该使用比较高级的并发工具来代替。比如:Executor Framework(ExecetorService)、并发集合以及同步器Synchronizer(屏障、闭锁)。
并发集合中不可能排除并发获得,将它锁定没什么用,只会使程序的性能变慢。尽量使用并发容器比如ConcurrentHashMap,而不是使用Collections.synchronizedMap。
同步器是使线程能够等待另一个线程的对象,允许它们协调工作。最常见的同步容器是CountDownLatch和Semaphore。还有CyclicBarrier和Exchang。
总而言之,直接使用wait和notify就像使用"并发汇编语言"编程一样,而java.util.concurrent包则提供了更高级的语言。没有理由在新代码中使用wait方法和notify方法,即使有也是极少数的。如果你在维护wait、notify的代码,务必确保内部是利用标准的模式从while循环内部调用wait方法。一般情况下,应该优先使用notifyAll方法,而不是使用notify方法。如果使用notify方法,请一定要小心确保程序的灵活性。
82. 文档应包含线程安全属性
要启用安全的并发使用,类必须清楚地记录它支持的线程安全级别。如下:
(1)不可变的:这个类的实例看起来是常量,不需要同步,比如String、Long、BigInteger
(2)无条件线程安全:该类内部有足够的线程安全,比如AtomicLong和ConcurrentHashMap等。
(3)有条件的线程安全:比如Collections.synchronized包装器返回的集合,其迭代器需要外部同步。
(4)非线程安全:该类的实例是可变的,要并发地使用它们,客户端需要同步,比如ArrayList、HashMap
(5)线程对立: 即使每个方法调用都被外部同步包围,该类对于并发使用也是不安全的。线程对立通常是由于在不同步的情况下修改静态数据而导致的。没有人故意编写线程对立类;此类通常是由于没有考虑并发性而导致的。当发现类或方法与线程不相容时,通常将其修复或弃用。
注意:lock字段必须声明为final,防止被修改引用。可以使用一个内部私有的对象锁进行lock。
83. 明智审慎的使用延迟初始化
延迟初始化是延迟字段的初始化,直到需要它的值。这种技术适用于静态字段也适用于实例字段。
在存在多个线程的情况下,使用延迟初始化很棘手。如果两个或多个线程共享一个延迟初始化的字段,那么必须使用某种形式的同步,否则会导致严重的错误
在多数情况下,常规初始化优于延迟初始化。
如果需要使用延迟初始化来提高实例字段的性能,请使用双重检查模式。这个模式避免了初始化后访问字段时的锁定成本。这个模式背后的思想是两次检查字段的值(因此得名 double check):一次没有锁定,然后,如果字段没有初始化,第二次使用锁定。只有当第二次检查指示字段未初始化时,调用才初始字段。由于初始化字段后没有锁定,因此将字段声明为 volatile 非常重要:
// Double-check idiom for lazy initialization of instance fields private volatile FieldType field; private FieldType getField() { FieldType result = field; if (result == null) { // First check (no locking) synchronized (this) { if (field == null) // Second check (with locking) field = result = computeFieldValue(); } } return result; }
84. 不要依赖线程调度器
任何依赖线程调度器来保证正确性或性能的程序都可能是不可移植的。
编写健壮、响应快、可移植程序的最佳方法是确保可运行线程的平均数量不显著大于处理器的数量。这使得线程调度器几乎没有选择:它只运行可运行线程,直到它们不再可运行为止。即使在完全不同的线程调度策略下,程序的行为也没有太大的变化。注意,可运行线程的数量与线程总数不相同,后者可能更高。正在等待的线程不可运行。
保持可运行线程数量低的主要技术是让每个线程做一些有用的工作,然后等待更多的工作。如果线程没有做有用的工作,它们就不应该运行。 对于 Executor 框架,这意味着适当调整线程池的大小,并保持任务短小(但不要太短),否则分派的开销依然会损害性能。总之,不要依赖线程调度器来判断程序的正确性。生成的程序既不健壮也不可移植。因此,不要依赖Thread.yield 或线程优先级。这些工具只是对调度器的提示。线程优先级可以少量地用于提高已经工作的程序的服务质量,但绝不应该用于「修复」几乎不能工作的程序。
85. 优先选择Java序列化的替代方法
序列化是危险的,应该避免。如果可以,用JSON(文本)或者Protobuf(二进制)代替,永远不要反序列化不可信的东西。如果必须这么做,请使用对象反序列化过滤。
86. 非常谨慎的实现Serializable
实现该接口的一个重要代价就是,一旦类的实现被发布,它就会降低更改该类实现的灵活性。
第二个代价是增加了出现bug和安全漏洞的可能性。
第三个代价是,它增加了与发布类的新版本相关的测试负担。
87. 考虑自定义的序列化形式
无论选择哪种序列化形式,都要在编写的每个可序列化类中声明显式的序列版本 UID。
private static final long serialVersionUID = randomLongValue;
88. 保护性的编写 readObject 方法
89. 对于实例控制,枚举类型优于 readResolve
90. 考虑用序列化代理代替序列化实例
序列化代理模式相当简单。首先,为可序列化的类设计一个私有的静态嵌套类,精确地表示外围类的逻辑状态。这个嵌套类被称为序列化代理(seralization proxy),它应该有一个单独的构造器,其参数类型就是那个外围类。这个构造器只是从它的参数中复制数据:它不需要进行任何一致性检验或者保护性拷贝。从设计的角度看,序列化代理的默认序列化形式是外围类最好的序列化形式。外围类及其序列代理都必须声明实现 Serializable 接口。