Java并发编程实践

时间:2024-03-29 17:05:08

最近阅读了《Java并发编程实践》这本书,总结了一下几个相关的知识点。

线程安全

当多个线程访问某个类时,不管运行时环境采用何种调度方式或者这些线程将如何交替执行,并且在主调代码中不需要任何额外的同步或协同,这个类都能表现出正确的行为,那么就称这个类是线程安全的。可以通过原子性一致性不可变对象线程安全的对象加锁保护同时被多个线程访问的可变状态变量来解决线程安全的问题。

可见性

在没有同步的情况下,编译器、处理器以及运行时等都可能对操作的执行顺序进行一些意想不到的调整。在缺乏足够同步的多线程程序中,要想对内存操作的执行顺序进行判断,几乎无法得出正确的结论。加锁的含义不仅仅局限于互斥行为,还包括内存可见性。为了确保所有线程都能看到共享变量的最新值,所有执行读写操作的线程都必须持有同一把锁。volatile变量不会被缓存在寄存器或者对其他处理器不可见的地方,因此在读取volatile类型的变量时总会返回最新写入的值。volatile变量是一种比synchronized关键字更轻量级的同步机制。加锁机制即可以确保可见性又可以确保原子性,而volatile变量只能确保可见性

发布逸出

当从对象的构造函数中发布对象时,只是发布了一个尚未构造完成的对象。即使发布对象的语句位于构造函数的最后一行也是如此。如果this引用在构造函数中逸出,那么这种现象就被认为是不正确构造。常见的逸出有,在构造函数中创建并启动一个线程、内部私有可变状态逸出等。

要安全地发布一个对象,对象的引用以及对象的状态必须同时对其他线程可见。一个正确构造的对象可以通过一下方式来安全地发布:

  • 在静态初始化函数中初始化一个对象引用
  • 将对象的引用保存到volatile类型的域或者AtomicReference对象中
  • 将对象的引用保存到某个正确构造对象的final类型域中
  • 将对象的引用保存到一个由锁保护的域中

对象的发布需求取决于它的可变性:

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

千万不要在A线程中创建对象,在B线程中使用该对象。在对象初始化的时候,首先会去申请一个内存空间,然后给对象中的属性赋默认值(如:int类型的变量默认值为0等),再通过构造函数或者代码块对属性进行赋值,最后地址空间指向的对象才算是创建完成了(当然还有很多其他的步骤,这里只是简单说明一下)。这样很有可能出现B线程获取到的对象是不完整的,因为Java线程模型的和对象的可见性的原因。

线程中断

调用Thread.interrupt()并不意味着立即停止目标线程正在进行的工作,而只是传递了请求中断的消息。

对中断操作的正确理解是:它并不是真正地中断一个正在运行的线程,而只是发出中断请求,然后由线程在下一个合适的时刻中断自己。(这些时刻也被称为取消点)。有些方法,例如:Object.wait()Thread.sleep()Thread.join()等,将严格地处理这种请求,当它们收到中断请求或者在开始执行时发现某个已被设置好的中断状态时,将抛出一个异常。

在使用静态的interrupted时应该小心,因为它会清除当前线程的中断状态。如果在调用interrupted时返回了true,那么除非你想屏蔽这个中断,否则必须对它进行处理—可以抛出InterruptedException,或者通过再次调用interrupt()来恢复中断状态。Future.cancel()方法可以取消线程。

通常,中断是实现取消的最合理方式

未捕获的异常

在运行时间较长的应用程序中,通常会为所有线程的未捕获异常指定同一个异常处理器(实现Thread.UncaughtExceptionHandler接口),并且该处理器至少会将异常信息记录到日志中。

如果你希望在任务由于发生异常和失败时获得通知,并且执行一些特定于任务的居处操作,那么可以将任务封装在能捕获异常的RunnableCallable中,或者改写ThreadPoolExecutor.afterExecute()方法。

只有通过execute()提交的任务,才能将它抛出的异常交给未捕获异常处理器,而通过submit提交的任务的异常都被封装在Future.get()ExecutionException中重新抛出。

JVM关闭

关闭钩子是指通过Runtime.addShutdownHook注册的但尚未开始的线程。JVM并不能保证关闭钩子的调用顺序。在关闭应用程序线程时,如果有(守护或非守护)线程仍然在运行,那么这些线程接下来将与关闭进程并发执行。当所有的关闭钩子都执行结束时,如果runFinalizersOnExittrue,那么JVM将运行终结器,然后再停止。

关闭钩子应该是线程安全的。它们在访问共享数据时必须使用同步机制,并且小心地避免发生死锁,这与其他并发代码的要求相同。而且,关闭钩子不应该对应用程序的状态或者JVM的关闭原因做出任何假设,因此在编写关闭钩子的代码时必须考虑周全。

关闭ExecutorService

ExecutorService提供了两种关闭方法:

  • ExecutorService.shutdown():正常关闭
  • ExecutorService.shutdownNow():强行关闭

    这两种关闭方式的差别在于各自的安全性响应性:强行关闭的速度更快,但风险也更大,因为任务很可能在执行到一半时被结束;而正常关闭虽然速度慢,但却更安全,因为ExecutorService会一直等到队列中的所有任务都执行完成后才关闭。在其他拥有线程的服务中也应该考虑提供类似的关闭方式以供选择。

\(\circ\) 正常关闭

try{
// 正常关闭
executorService.shutdown();
// 等待指定时间直到结束,超时会抛出InterruptedException异常
executorService.awaitTermination(timeout, unit);
}catch(InterruptedException ex){
// do something
}

\(\circ\) 强行关闭

try{
// 强行关闭
List<Runnable> unfinishedTasks = executorService.shutdownNow();
// 处理未完成的任务
handle(unfinishedTasks);
// 等待指定时间直到结束,超时会抛出InterruptedException异常
executorService.awaitTermination(timeout, unit);
}catch(InterruptedException ex){
// do something
}

资源释放

调用的方法 CPU
Thread.sleep() 不释放 释放
Thread.join() 不释放 释放
Thread.yield() 不释放 释放
Object.wait() 释放 释放
Condition.await() 释放 释放

ThreadPoolExecutor

public ThreadPoolExecutor(int corePoolSize,
int maximumPoolSize,
long keepAliveTime,
TimeUnit unit,
BlockingQueue<Runnable> workQueue,
ThreadFactory threadFactory,
RejectedExecutionHandler handler)
  • corePoolSize 线程池核心线程大小

在创建了线程池后,默认情况下,线程池中并没有任何线程,而是等待有任务到来才创建线程去执行任务,(除非调用了prestartAllCoreThreads()或者prestartCoreThread()方法,从这2个方法的名字就可以看出,是预创建线程的意思,即在没有任务到来之前就创建corePoolSize个线程或者一个线程)。

默认情况下,在创建了线程池后,线程池中的线程数为0,当有任务来之后,就会创建一个线程去执行任务,当线程池中的线程数目达到corePoolSize后,就会把到达的任务放到缓存队列当中。核心线程在allowCoreThreadTimeout被设置为true时会超时退出,默认情况下不会退出。

  • maximumPoolSize 线程池最大线程数

当线程数大于或等于核心线程,且任务队列已满时,线程池会创建新的线程,直到线程数量达到maximumPoolSize。如果线程数已等于maximumPoolSize,且任务队列已满,则已超出线程池的处理能力,线程池会拒绝处理任务而抛出异常。

  • keepAliveTime 空闲线程存活时间

当线程空闲时间达到keepAliveTime,该线程会退出,直到线程数量等于corePoolSize。如果allowCoreThreadTimeout设置为true,则所有线程均会退出直到线程数量为0

  • unit 空间线程存活时间单位

keepAliveTime的计量单位

  • workQueue 工作队列

新任务被提交后,会先进入到此工作队列中,任务调度时再从队列中取出任务。JDK中提供了四种工作队列:

\(\circ\) ArrayBlockingQueue 基于数组的有界阻塞队列,按FIFO排序。新任务进来后,会放到该队列的队尾,有界的数组可以防止资源耗尽问题。当线程池中线程数量达到corePoolSize后,再有新任务进来,则会将任务放入该队列的队尾,等待被调度。如果队列已经是满的,则创建一个新线程,如果线程数量已经达到maximumPoolSize,则会执行拒绝策略。

\(\circ\) LinkedBlockingQuene 基于链表的*阻塞队列(其实最大容量为Interger.MAX),按照FIFO排序。由于该队列的近似*性,当线程池中线程数量达到corePoolSize后,再有新任务进来,会一直存入该队列,而不会去创建新线程直到maximumPoolSize,因此使用该工作队列时,参数maximumPoolSize其实是不起作用的。

\(\circ\) SynchronousQuene 一个不缓存任务的阻塞队列,生产者放入一个任务必须等到消费者取出这个任务。也就是说新任务进来时,不会缓存,而是直接被调度执行该任务,如果没有可用线程,则创建新线程,如果线程数量达到maximumPoolSize,则执行拒绝策略。

\(\circ\) PriorityBlockingQueue 具有优先级的*阻塞队列,优先级通过参数Comparator实现。

  • threadFactory 线程工厂

创建一个新线程时使用的工厂,可以用来设定线程名是否为daemon线程Thread.UncaughtExceptionHandler等等。

  • handler 拒绝策略

当工作队列中的任务已到达最大限制,并且线程池中的线程数量也达到最大限制,这时如果有新任务提交进来,该如何处理呢。这里的拒绝策略,就是解决这个问题的,JDK中提供了4中拒绝策略:

\(\circ\) CallerRunsPolicy 该策略下,在调用者线程中直接执行被拒绝任务的run方法,除非线程池已经shutdown,则直接抛弃任务。

\(\circ\) AbortPolicy 该策略下,直接丢弃任务,并抛出RejectedExecutionException异常。

\(\circ\) DiscardPolicy该策略下,直接丢弃任务,什么都不做。

\(\circ\) DiscardOldestPolicy 该策略下,抛弃进入队列最早的那个任务,然后尝试把这次拒绝的任务放入队列

条件队列

条件队列使得一组线程(称之为等待线程集合)能够通过某种方式来等待特定的条件变成真。传统队列的元素是一个个数据,而与之不同的是,条件队列中的元素是一个个正在等待相关条件的线程。

正如每个Java对象都可以作为一个锁,每个对象同样可以作为一个条件队列,并且ObjectwaitnotifynotifyAll方法就构成了内部条件队列的API。对象的内置锁与其内部条件队列是相互关联的,要调用对象X中条件队列的任何一个方法,必须持有对象X上的锁。这就是因为“等待由状态构成的条件”与“维护状态一致性”这两种机制必须被紧密地绑定在一起:只有能对状态进行检查时,才能在某个条件上等待,并且只有能修改状态时,才能从条件等待中释放一个线程。

当使用条件等待时(例如Object.waitCondition.await

  • 通常都有一个条件谓词,包括一些对象状态的测试,线程在执行前必须首先通过这些测试
  • 在调用wait之前测试条件谓词,并且从wait中返回是再次进行测试
  • 在一个循环中调用wait
  • 确保使用与条件队列相关的锁来保护构成条件谓词的各个状态变量
  • 当调用waitnotifynotifyAll等方法时,一定要持有与条件队列相关的锁
  • 在检查条件谓词之后以及开始执行相应的操作之前,不要释放锁

降低锁竞争程度的几种方式

  • 减少锁的持有时间
  • 降低锁的请求频率
  • 使用带有协调机制的独占锁,这些机制允许更高的并发性

CAS操作

CAS包含3个操作数:需要读写的内存位置V、进行比较的值A和拟写入的新值B。当且仅当V的值等于A时,CAS才会通过原子方式用新值B来更新V的值,否则不会执行任何操作。无论位置V的值是否等于A,都将返回V原有的值。

CAS的主要缺点是:它将使调用者处理竞争问题(通过重试、回退、放弃),而在锁中能自动处理竞争问题,同时CAS还会出现ABA的问题。

Java内存模型(JMM)

在共享内存的多处理器体系架构中,每个处理器都拥有自己的缓存,并且定期地与主内存进行协调。在不同的处理器架构中提供了不同级别的缓存一致性(Cache Coherence),其中一部分只提供最小的保证,即允许不同的处理器在任意时刻从同一个存储位置上看到不同的值。操作系统、编译器以及运行时(有时甚至包括应用程序)需要弥合这种硬件能力与线程安全需求之间的差异。

Java内存模型是通过各种操作来定义的,包括对变量的读写操作,监视器的加锁和释放操作,以及线程启动和合并操作。JMM为程序中所有的操作定义了一个偏序关系,称之为Happens-Before。如果两个操作之间缺乏Happens-Before关系,那么JVM可以对它们任意的重排序。

当一个变量被多个线程读取并且至少被一个线程写入时,如果在读操作和写操作之间没有依照Happens-Before来排序,那么就会产生数据竞争的问题。在正确同步的程序中不存在数据竞争,并会表现出串行一致性,这意味着程序中的所有操作都会按照一种固定的和全局的顺序执行。

Happens-Before的规则包括

  • 程序顺序规则。如果程序中操作A在操作B之前,那么在线程中A操作将在B操作之前执行。
  • 监视器锁规则。在监视器锁上的解锁操作必须在同一个监视器锁上的加锁操作之前执行。
  • volatile变量规则。 对volatile变量的写入操作必须在对该变量的读操作之前执行。
  • 线程启动规则。在线程上对Thread.start()的调用必须在该线程中执行任何操作之前执行。
  • 线程结束规则。线程中的任何操作都必须在其他线程检测到该线程已经结束之前执行,或者从Thread.join() 中成功返回,或者在调用Thread.isAlive()时返回false
  • 中断规则。当一个线程在另一个线程上调用interrupt时,必须在被中断线程检测到interrupt调用之前执行(通过抛出InterruptedException,或者调用isInterruptedinterrupted)。
  • 终结器规则。对象的构造函数必须启动在该对象的终结器之前执行完成。
  • 传递性。如果操作A在操作B之前执行,并且操作B在操作C之前执行,那么操作A必须在操作C之前执行。