Java编程思想拾遗(15) 并发

285 阅读14分钟

注:并发涵盖的知识点较多,本专栏侧重于梳理与拾遗,所以本文没有进行详细的知识点介绍与demo演示哈,不过笔者后面有可能会出一期JDK源码阅读专栏,里面会重点关注并发相关的类库

如果你有一台多处理器的机器,那么就可以在这些处理器之间分布多个任务,从而极大地提高吞吐量,但是并发通常是指提高运行在单处理器上的程序的性能(或许这就是所谓的并行和并发的概念区别)。

如果使用并发来编写程序,那么当一个任务阻塞时,程序中的其他任务还可以继续执行,因此这个程序整体就可以继续保持向前执行。单处理器系统中性能提高的常见示例是事件驱动的编程,通过创建单独的执行线程进行事件监听,可以避免所有任务都周期性地检查事件输入,即使这个线程在大多数时间都是阻塞的,程序依然可以保证具有一定程度的可响应性。

线程调度

Java在顺序型语言的基础上提供对线程的支持,与在多任务操作系统中分叉外部进程不同,线程机制是在由执行程序表示的单一进程中创建任务,这种方式产生的一个好处是操作系统的透明性,这对Java而言,是一个重要的设计目标。

Java的线程机制是抢占式的,这表示调度机制会周期性地中断线程,将上下文切换到另一个线程,从而为每个线程都提供时间片,使得每个线程都会分配到数量合理的时间去驱动它的任务(注意,这里说明的是线程间的切换,而不是线程内部排队任务的切换,该老实排队的还是得老实排队)。

线程的优先级将该线程的重要性传递给调度器,尽管CPU处理现有线程集的顺序是不确定的,但是调度器将倾向于让优先级高的线程来执行,然而这并不意味着优先级较低的线程将得不到执行,它们仅仅是执行的频率较低。

如果知道已经完成了在run()方法中循环一次迭代中所需的工作,就可以通过调用yield()方法给线程调度一个暗示:我的工作已经做得差不多了,可以让别的线程使用CPU了,不过这只是一个暗示,没有任何机制保证它将会被采纳,对于任何重要的控制都不能依赖于yield()。

后台daemon线程,是指在程序运行时在后台提供服务的线程,当所有的非后台线程结束时,程序也就终止了,同时会杀死进程中的所有后台线程(此时finally是不会被保证的,毕竟连指令都不执行了),反过来说,只要有任何非后台线程还在运行,程序就不会终止。

线程状态

一个线程可以处于以下四种状态之一:

  1. 新建(New):当线程被创建时,它只会短暂地处于这种状态,此时它已经分配了必需的系统资源,并执行了初始化。此刻线程已经有资格获得CPU时间了,之后调度器将把这个线程转变为可运行状态或阻塞状态。
  2. 就绪(Runnable):在这种状态下,只要调度器把时间分配给线程,线程就可以运行。也就是说,在任意时刻,线程可以运行也可以不运行,这不同于阻塞和死亡状态。
  3. 阻塞(Blocked):线程能够运行,但有某个条件阻止它的运行,当线程处于阻塞状态时,调度器将忽略线程,不会分配给线程任何CPU时间,直到线程重新进入了就绪状态,它才有可能执行操作。
  4. 死亡(Dead):处于死亡或者终止状态的线程将不再是可调度的,并且再也不会得到CPU时间,它的任务已结束,或不再是可运行的。任务死亡的通常方式是从run()方法返回,但是任务的线程还可以被中断。
stateDiagram-v2
[*] --> New
New --> Runnable
Runnable --> Blocked
Blocked --> Runnable
Blocked --> Dead
Runnable --> Dead
Dead --> [*]

一个任务进入阻塞状态,可能有如下原因:

  • 通过调用sleep(millseconds)使任务进入休眠状态。
  • 通过调用wait()使线程挂起,直到线程得到了notify()或notifyAll()消息,线程才会进入就绪状态。
  • 任务在等待某个输入/输出完成。
  • 任务试图在某个对象上调用其同步控制方法,但是对象锁不可用,因为另外一个任务已经获取了锁。

Thread类包含interrupt()方法,因此你可以终止被阻塞的任务,这个方法将设置线程的中断状态。如果一个线程已被阻塞,或者试图执行一个阻塞操作,那么设置这个线程的中断状态将抛出InterruptedException,当抛出该异常或者该任务调用Thread.interrupted()时,中断状态将被复位,复位可以达到中断通知只有一次的效果。Thread.isInterrupted()单纯判断不会进行复位。

不是所有阻塞状态都是可以直接响应中断的,你能够中断对sleep()的调用,但是你不能中断正在试图获取synchronized锁或者试图执行I/O操作的线程。前者可以改用Lock进行中断(这是synchronized和Lock的区别之一),后者可以通过关闭底层资源抛出IOException,或者改用NIO然后用Future.cancel(true)。

中断检查的正确姿势:异常捕获和主动检查结合,中间记得做好try-finally资源清理。

class NeedCleanup {
    private final int id;
    public NeedsCleanup(int ident) {
        id = ident;
        print("NeedCleanup " + id);
    }
    public void cleanup() {
        print("Cleaning up " + id);
    }
}

class Blocked3 implements Runnable {
    private volatile double d = 0.0;
    public void run() {
        try {
            while(!Thread.interrupted()) {
                NeedsCleanup n1 = new NeedsCleanup(1);
                try {
                    print("Sleeping")
                    TimeUnit.SECONDS.sleep(1);
                } finally {
                    n1.cleanup();
                }
            }
            print("Exiting via while() test");
        } catch (InterruptedException e) {
            print("Exiting via InterruptedException");
        }
    }
}

任务调用

任务需要附着到一个线程上,让线程去执行任务期望的逻辑,Thread类本身不执行任何操作,它只是驱动赋予它的任务。

Java的执行器Executor在客户端和任务执行之间提供了一个间接层,它接管了Thread的生命周期,对于线程复用与任务调度有很大的帮助。通过编写ThreadFactory可以定制由Executor创建的线程的属性:后台、优先级、名称等。

如果你希望任务在完成时能够返回一个值,那么可以实现Callable接口而不是Runnable接口,并用ExecutorService.submit()方法返回一个Future对象,其可以持有返回值(如果需要更加灵活的应用,可以选择CompletableFuture)。

异常处理

异常不能跨线程传播回main(),所以你必须在本地处理所有在任务内部产生的异常。一旦异常逃出任务的run()方法,它就会向外传播到控制台(这里只是说明异常捕获问题,不代表某一线程发生异常会影响到其他线程执行)。

public class NaiveExceptionHandling {
    public static void main(String[] args) {
        try {
            ExecutorService exec = Executors.newCachedThreadPool();
            exec.execute(new ExceptionThread());
        } catch(RuntimeException e) {
            // This statement will NOT execute!
            System.out.println("Exception has been handled");
        }
    }
}

staitc class ExceptionThread implements Runnable {
    public void run() {
        throw new RuntimeException();
    }
}

Thread.UncaughtExceptionHandler允许你在每个Thread对象上都附着一个异常处理器,ThreadFactory也支持。

资源竞争

基本上所有的并发模式在解决线程冲突问题的时候,都是采用序列化访问共享资源的方案,这种通过锁语句产生互相排斥的效果被称为互斥量。

synchronized

Java以提供关键字synchronized的形式,对防止资源冲突提供了内置支持。所有对象都自动包含单一的锁,这也称为监视器,当在对象上调用其任意synchronized方法时,此对象会被加锁,同一对象其他任意的方法调用都会被阻塞(以对象为锁粒度,而不是以方法为锁粒度)。class类对象也属于对象,也支持用synchronized加锁,static方法调用时就用的此锁。

如果在你的类中有超过一个方法在处理临界数据,那么你必须同步所有相关的方法,如果只同步一个方法,那么其他方法将会随意地忽略这个对象锁,并可以在无任何惩罚的情况下被调用。

synchronized锁是可重入的,但用户无需在代码上关心其影响。

Lock

Java类库在java.util.concurrent.locks中提供了显式的互斥机制--Lock对象。它与内建的锁形式相比,代码稍微缺乏优雅性,但是对于解决某些类型的问题来说,它更加灵活,比如用synchronized关键字不能支持tryLock逻辑,也不支持超时获取机制。

Semaphore

正常的锁在任何时刻都只允许一个任务访问一项资源,而计数信号量允许n个任务同时访问这个资源,你可以将信号量看作是在向外分发使用资源的许可证。

volatile

原子性可以应用于除long和double之外的基本类型之上的简单操作(仅限set和get),JVM可以将64位long和double变量的读取和写入当作两个分离的32位操作来执行,这就产生了在一个读取和写入操作中间发生上下文切换从而导致不同的任务可以看到不正确结果的可能性,如果使用volatile关键字,就可以获得原子性。原子操作可由线程机制来保证其不可中断,这些代码不需要同步。

volatile关键字还确保了应用中的可视性,如果你将一个域声明为volatile的,那么只要对这个域产生了写操作,那么所有的读操作就都可以看到这个修改,volatile域会被立即写入到主存中,而读取操作就发生在主存中。同步也会导致向主存中刷新,因此如果一个域完全由synchronized方法或语句块来防护,那就不必将其设置为是volatile的。

当一个域的值依赖于它之前的值时,比如递增一个计数器(这个操作不是原子的!),volatile就无法工作了。使用volatile而不是synchronized的唯一安全的情况是类中只有一个可变的域(其实更合适的说法是需要同步控制的域只有一个,并且只有简单操作时)。

Actomic

Java引入了诸如AtomicInteger、AtomicLong、AtomicReference等特殊的原子性变量类,它们提供下面形式的原子性条件更新操作:

boolean compareAndSet(expectedValue, updateValue);

这些类被调整为可以使用在某些现代处理器上,并且在机器级别上是原子性的,可以解决上面的递增问题。

对于常规编程来说,它们很少会派上用场,但是在涉及性能调优时,它们就大有用武之地了,cas属于乐观锁,不会进行互斥加锁,只是需要使用者自行控制设置失败后的策略。

Container

Collections类提供了各种static的同步的装饰方法,从而来同步不同类型的容器。尽管这是一种改进,因为它使你可以选择在你的容器中是否要使用同步,但是这种开销仍旧是基于synchronized加锁机制的,为此Java添加了一些并发类容器,通过使用更灵巧的技术来消除加锁,从而提高线程安全的性能,这其中包含:CopyOnWriteArrayList、CopyOnWriteArraySet、ConcurrentHashMap、ConcurrentLinkedQueue、ReadWriteLock。

ThreadLocal

防止任务在共享资源上产生冲突的第二种方式是根除对变量的共享。线程本地存储是一种自动化机制,可以为使用相同变量的每个不同的线程都创建不同的存储(但其实在应用上是反过来,各线程恰好需要用ThreadLocal去维护一个自己的私有变量,而不是用ThreadLocal去避免资源竞争)。

DeadLock

死锁发生的条件:

  • 互斥并且不能抢占。任务使用的资源中至少有一个是不能共享的,已经被持有的资源只能等原任务主动释放才能被其他任务占有。
  • 至少有一个任务持有一个资源且在等待获取一个当前被别的任务持有的资源。
  • 必须有循环等待。

防止死锁最容易的方法就是破坏循环等待。

线程协作

任务可以将其自身挂起,直到某些外部条件发生变化,表示是时候让这个任务向前开动了为止。当任务协作时,关键问题是这些任务之间的握手,为了实现这种握手,我们使用了相同的基础特性:互斥,互斥能够确保只有一个任务可以响应某个信号,这样可以根除任何可能的竞争条件。

wait()与notify()

当一个任务在方法里遇到了对wait()的调用时,线程的执行被挂起,对象上的锁被释放,这把锁与synchronized对象锁是同一个,所以调用wait()和notify()前需要先拥有对象锁。sleep()和yield()则不同,其操作与任一对象无关,因此也就与锁无关。

注意等待条件判断不能在获取锁之前,不然有可能会丢失信号。

    T1:
    synchronized(shareMonitor) {
        <setup condition for T2>
        shareMonitor.notify();
    }
    
    T2: 
    synchronized(shareMonitor) {
        while(someCondition) {
            shareMonitor.wait();
        }
    }

Lock与Condition

使用互斥并允许任务挂起的基本类是Condition,你可以通过在Condition上调用await()来挂起一个任务,当外部条件发生变化,意味着某个任务应该继续执行,你可以通过调用signal()来通知这个任务,从而唤醒一个任务。

互斥上跟wait()差不多,wait()前需要获取Object锁,Condition.await()前需要获取Lock锁。虽然编码上稍微复杂点,但是并发控制可以更加灵活,跟synchronized形成对称之势。

BlockingQueue

在许多情况下,你可以瞄向更高的抽象级别,使用同步队列来解决任务协作问题。同步队列在内部已经对互斥和协作进行了支持,对使用者来说编程更加友好优雅,不用再显式使用synchronized等关键字进行控制,同步队列对于线程间使用消息对象传递协作的场景是最合适的选择。

CountDownLatch和CycleBarrier

CountDownLatch被用来同步一个或多个任务,强制它们等待由其他任务执行的一组操作完成,典型用法就是倒数计数器,由各个并发完成任务并减少次数,次数直至0就可以解除阻塞状态。

CycleBarrier适用于创建一组任务,它们并行地执行工作,然后在进行下一个步骤之前等待,直至所有任务都完成(是join的升级版),它使得所有的并发任务都将在栅栏处列队,因此可以一致地向前移动,还支持在栅栏处执行一次公共任务。

CountDownLatch只触发一次事件,而CycleBarrier可以多次重用。

扩展想象

有一种可替换的方式被称为活动对象,每个对象都维护着它自己的工作器线程和消息队列(消息队列yyds),并且所有对这种对象的请求都将进入队列排队,任何时刻都只能运行其中的一个,因此,有了活动对象,我们就可以串行化消息而不是方法,这意味着不再需要防备一个任务在其循环的中间被中断这种问题了。

专栏小结

这是本专栏最后一篇文章了,从开栏到现在,历时3周用业余时间完成了15篇文章。

首次阅读《Java编程思想》时我还只是一名一知半解的大二学生,如今我已经经历过一些个人项目和企业项目,中间还穿插过golang项目,此次二刷温故而知新,对Java语言结构的设计有了更加熟悉的认知,同时从字里行间和一些编程细节,对作者的编程思想和编程技巧也愈加叹服,如果不是此书,估计当初也未必会如此坚定地选择Java去做自己的主力语言。

按道理Java相关还可以继续拾遗的内容有JDK源码和JVM,不过下一个专栏我大概率会换换口味,可能会搞个分布式一致性协议理论实验篇什么的吧。