Summer Blog

Java并发编程学习笔记

二、 线程安全性

2.1 什么是线程安全

2.2 原子性

2.3 加锁机制

2.4 用锁保护状态

用锁保护的原则:访问共享状态的符合操作,都必须是原子性的以避免产生竞态条件。如果用同步来协调对某个变量的访问时,那么在访问这个变量的所有位置上都需要使用同步。而且当用锁协调对某个变量的访问时,在访问变量的所有位置都需要使用相同的锁。

一种常见的加锁约定是,将所有的可变状态都封装在对象的内部,并通过对象的内置锁对所有访问可变状态的代码路径进行同步,使得在该对象上不会发生并发访问。例如Vector和其他的同步集合类。

2.5 活跃性与性能

决定synchronized块的大小需要权衡各种设计,包括安全性(不能妥协)、简单和性能。有时简单和性能会彼此冲突,但不要过早的为了性能而牺牲简单性。

三、共享对象

可见性

可见性问题可能导致读到失效数据。对于一个变量,没有同步的情况下,读取到的可能是一个之前的值,但如果是 64 位( doublelong )类型的数据,可能高低位读取到不同的过期值的高低位。因此,在多线程环境中即便不担心失效数据问题,使用 64 位数据还是要做同步保护。

加锁的含义除了互斥外,还包括内存可见性。对于多线程中共享的可变变量,在同一个锁上同步可以保证每个线程都读到最新值。

volatile

  1. 语意:
    1. 保证可见性。在变量改变需要依赖当前值,或者需要与其他变量共同参与不变性约束时需要额外同步来保证原子性
    2. 禁止指令重排优化。通过在编译时加入一个 lock 前缀指令,相当于内存屏障
  2. 当且仅当完全满足以下条件时,才可以使用 volatile:
    1. 写入不依赖当前值,或者保证只有单线程修改这个值
    2. 该变量的值不会与其他状态变量一起纳入不变性条件
    3. 在访问变量时不需要加锁

线程封闭

不共享数据,仅在单线程中访问数据,例如 JDBC 的Connection对象,仅在一个线程中使用;ThreadLocal

不变性

不可变的对象一定是线程安全的。

一个变量被声明成final的表示它的值不能被改变。如果它是一个引用表示不能被重新指定指向的对象,但是其对象的值是可以改变的。我们必须初始化final变量,它只能被初始化一次。

安全发布

对象的安全发布需求取决与它的可变性:

  1. 不可变对象可以通过任意机制发布
  2. 事实不可变对象必须通过安全方式来发布
  3. 可变对象必须通过安全方式来发布,并且必须是线程安全的或者由某个锁保护起来

在并发程序中使用和共享对象时,可以通过以下策略安全地共享变量:

  1. 线程封闭:线程封闭对象只能由一个线程拥有
  2. 只读共享:没有任何同步机制下,多个线程只读一个对象是安全的,任何线程都不能修改它
  3. 线程安全的共享:线程安全对象在其内部实现同步,多线程通过对象的接口访问,而不需要进一步同步
  4. 保护对象:通过持有特定的锁来访问

四、对象组合

设计线程安全的类

在设计线程安全的类时,需要包含以下三个基本要素:

  1. 找出构成对象状态的所有变量
  2. 找出约束变量的不变性条件
  3. 建立对象状态的并发访问管理策略

方法:

五、基础构建模块

同步容器

同步容器包括VectorHashTable,还有由通过Collections.synchronizedXxx构造的类。它们实现线程安全的方式是:将状态封装起来,并对每个共有方法都进行同步,使得每次只有一个线程可以访问容器的状态。同步容器对所有状态的访问都串行化的代价是严重减低了并发性。在某些情况下要对它们进行额外的加锁来保护复合操作,如迭代、跳转、若没有则添加。

同步容器在迭代的时候,如果发现被其他线程修改会抛出ConcurrentModificationException异常。防止异常发生可以在迭代之前加锁保护,降低并发,引起死锁;复制一个副本在副本上完成迭代,复制有消耗。

并发容器

并发容器是针对多个线程同时访问设计的,使用并发容器代替同步容器,可以极大的提高伸缩性并减低风险。并发容器在迭代的过程中不会抛出ConcurrentModificationException异常,而是采用弱一致性设计,可以容忍并发的修改,当创建迭代器时会遍历已有的元素,并可以(但不保证)在迭代器被构造后将修改反映给容器。常用的并发容器有:ConcurrentHashMapCopyOnWriteArrayListQueueBlockingQueueConcurrentSkipListMapConcurrentSkipListSet

同步工具类

所有的同步工具类都包含一些特定的结构化的属性:它们封装一些状态,这些状态决定执行同步工具的类的线程是继续执行还是等待,此外还提供了一些方法对状态进行操作,以及另一些方法用于高效地等待同步工具类进入到预期状态。

七、取消和关闭

Java 没有提供任何机制,来安全地强迫线程停止手头的工作。只提供了中断的协作机制,使一个线程能够要求另一个线程停止当前的工作。当要求它们停止时,它们首先会清除当前进程中的工作,然后再终止。这提供了更好的灵活性,因为任务代码本身比发出取消请求的代码更明确应该清除什么。

任务取消

我们取消一个任务的原因有很多,例如用户取消、现时活动、应用程序事件、错误、关闭程序或服务。

Java 使用协作机制来取消任务。在协作机制中,有一种会设置 cancellation requested 标志,任务定期查看;如果发现标志被设置过,任务就会提前结束。线程中断就是这样的协作机制,一个线程给另一个线程发送信号,通知它在方便或者可能的情况下停止正在做的工作。Java 并没有把中断与任何取消的语义绑定起来,但事实上,使用中断之外的任何方式取消都是不明智的,难以支撑起更大的应用。

对中断最好的理解是:它并不会真正中断一个正在运行的线程;它仅仅发出了中断请求,收到请求的线程会在下一个方便的时刻(取消点)中断。有些方法对这样的请求很重视,比如 waitsleepjoin ,会抛出InterruptedException,或者进入时中断状态就已经被设置了。

// 将调用该方法的对象所表示的线程标记一个停止标记,并不是真的停止该线程。
public void interrupt() {... }

// 获取当前线程的中断状态,并且会清除线程的状态标记。是一个是静态方法。
// 执行 interrupted() 后它会将状态标志清除,底层调用了 isInterrupted(true)
public static boolean interrupted() {...}

// 获取调用该方法的对象所表示的线程,不会清除线程的状态标记。是一个实例方法。
public boolean isInterrupted() {...}

中断策略

中断策略最有意义的是对线程级和服务级取消的规定:尽可能的快速退出,如果需要的话进行清理,可能的话通知其拥有的实体,这个线程已经推出。

响应中断

当调用可中断的阻塞函数时,有两种处理InterruptedException的实用策略:

  1. 传递异常,使你的方法也成为一个可中断的阻塞方法
  2. 保存中断状态,上层调用栈的代码能够对其进行处理

只有实现了线程的中断策略的代码才可以接收中断请求。通用目的的任务和库的代码绝不应该接受中断请求。

如果你的代码不会调用可中断的阻塞方法,仍可以通过检查任务代码当前线程的中断状态来响应终端。选择适当的检查频率需要在效率和响应性之间进行权衡。

通过Future来实现取消

Future拥有一个cannel()方法,该方法的参数mayInterruptIfRunning表示任务已经开始了是否还可以取消,取消成功会返回true。只有当你知道一个任务实现了中断策略时mayInterruptIfRunning设置为true才是安全的。

处理不可中断的阻塞

  1. Java.io 包中的 Socket I/O:关闭底层的套接字
  2. Java.io 包中的同步 I/O
  3. Selector 的异步 I/O:调用 closewakeup 方法会抛出异常
  4. 获取某个锁:如果等待某个内置锁,那么将无法相应中断。Lock类中lockInterruptibly方法允许等等锁的同时响应中断

停止基于线程的服务

对于线程持有的服务,只要服务的存在时间大于创建线程的方法存在的时间,那么就是应该提供生命周期的方法。ExecutorService提供了shutdownshutdownNow方法。

ExecutorService关闭

可以用shutdownshutdownNow方法关闭。shutdownNow表示立刻关闭,是强制关闭,可以通过进一步封装ExecutorService得到关闭后取消的任务。

致命药丸(poison pill)

可以使用致命药丸(poison pill)方式来保证生产者和消费者的关闭:一个可以识别的对象,置于队列中,如果得到它,就停止一切工作。只有在生产者和消费者的数量都已知的情况下,才能使用“毒丸”对象。

处理非正常的线程终止

线程非正常退出的后果可能是良性的,也可能是恶性的。若使用线程池,非正常终断会使线程池中的线程数减少。任何操作都可能带来RuntimeException,所以在调用方法时要保持怀疑。

JVM 关闭

正常关闭:

  1. 最后一个“正常(非守护)”结束
  2. 调用System.exit
  3. 特定平台的方法关闭

非正常关闭:

  1. 调用Runtime.halt
  2. 操作系统杀死 JVM 进程

关闭钩子(Shutdown Hook)

在正常关闭中,会执行系统中已经设置的所有通过方法 addShutdownHook 添加的钩子,当系统执行完这些钩子后,jvm 才会关闭。

注意点:

守护线程

用来做辅助功能的线程,不会阻碍 JVM 关闭。当 JVM 停止时,所有仍然存在的守护线程都将被抛弃(既不会执行 finally,也不会执行回卷栈),直接退出。应该尽量少用守护线程,因为很少有操作能够在不进行清理的情况下被安全的抛弃。特别注意,如果在守护线程中包含 I/O 操作的任务,是一件危险行为。

终结器

finalize 是基础类 java.lang.Object 的一个方法,它的设计目的是保证对象在被垃圾收集前完成特定资源的回收。finalize 机制现在已经不推荐使用,并且在 JDK 9 开始被标记为 deprecated。

finalize 的执行是和垃圾收集关联在一起的,一旦实现了非空的 finalize 方法,就会导致相应对象回收呈现数量级上的变慢,有人专门做过 benchmark,大概是 40~50 倍的下降。finalize 被设计成在对象被垃圾收集前调用,这就意味着实现了 finalize 方法的对象是个“特殊公民”,JVM 要对它进行额外处理。finalize 本质上成为了快速回收的阻碍者,可能导致你的对象经过多个垃圾收集周期才能被回收。


comments powered by Disqus