Java并发编程实战

2016年09月04日


layout: post title: Java并发编程实战 category: 学习 tags: java并发编程实战 keywords: description: —

第二章 线程安全性

编写线程安全的代码,其核心在于要对状态访问操作进行管理,特别是对共享(Shared)的和可变(Mutable)状态的访问.(将复合操作变为原子性操作)

对象的状态是指: 存储在状态变量(例如实例或者静态域)中的数据

多个线程访问一个可变的状态变量时没有使用合适的同步,那么程序就会出现错误.有三种方式可以修复这个问题:

  • 不在线程之间共享该状态的变化
  • 将状态变量修改为不可变的变量
  • 在访问状态变量时使用同步

竞态条件

可理解为: 执行结果依赖于执行顺序.. 与数据竞争并不等价

内置锁

synchronized 同步块所使用的锁. 非静态方法使用当前对象作为内置锁, 静态方法使用 对应的class对象作为内置锁

内置锁是可重入的,同一个线程可在未释放锁的情况下,调用另一个需内置锁的方法,且不会阻塞. (pthread线程,即POSIX线程 是什么东东?)

用锁来保护状态

活跃性与性能

执行较长时间的计算或者可能无法快速完成的操作时(网络I/O,控制台I/O),一定不要持有锁

第三章 对象的共享

synchronized 不仅用来实现原子性或者确定”临界区”, 还有另外一个重要的作用:内存可见性

可见性

在没有同步的情况下,编译器,处理器,以及运行时等都可能对操作的执行顺序进行一些意想不到的调整(指令重排序).从而导致一些无法对内存操作执行顺序进行判断

    /*
     这个类可能会持续循环下去, 因为ReaderThread可能永远也读不到ready的值,
     也有可能 ReaderThread会输出0(ReaderThread看到的ready的写入值,却没能看到number的写入值)

    \*/
    public class VisibilityThread {

        private static int number;
        private static boolean ready;

        public static void main(String[] args) {
            new ReaderThread().start();
            number = 42;
            ready = true;
        }

        private static class ReaderThread extends Thread {
            @Override
            public void run() {
                while (!ready) {
                    Thread.yield();
                }
                System.out.println(number);
            }
        }
    }

失效数据

上面的例子中锁读到的数据就是失效数据.

非原子的64位操作

java内存模型要求,变量的读取操作和写入操作都必须是原子操作,但是对应非volatile类型的long和double,JVM允许将64位的读取或者写入操作分解为两个32位的操作(如果读和写操作在不同线程中执行,可能会读到某个值的高32位和另一个值的低32位).使用volatile来声明,或者使用锁来进行保护才能获得正确的结果.

加锁与可见性

对共享变量的所有读操作和写操作线程都在同一个锁上同步,则可以保证所有线程都能看到共享变量的最新值, 所以加锁的含义不仅仅局限于互斥,还包括了内存可见性.

volatile变量

变量声明为volatile后,编译器与运行是都会注意到这个变量是共享的,所以不会将改变量上的操作与其他内存操作一起重排序,volatile变量不会被缓存在寄存器或者对其他处理器不可见的地方,因此读取volatile变量时总是会返回最新写入的值.

volatile不会执行加锁操作,也就不会执行线程阻塞,所以它是比sychronized更加轻量级的同步机制.(大多数处理器架构上,读取volatile变量的开销只比非volatile变量的开销略高一点)

volatile变量对可见性的影响: 在写入volatile变量前对线程A可见的所有变量的值,在线程B读取了volatile变量后,对线程B也是可见的.

加锁机制既能确保原子性,也能确保可见性, volatile只能确保可见性

当且仅当满足以下所有条件是,才应该使用volatile变量:

  • 对变量的写入操作不依赖变量的当前值, 或者你能确保只有单个线程更新变量的值.
  • 该变量不会与其他状态变量一起纳入不变性条件中(复合条件判断).
  • 在访问变量时不需要加锁.

发布和逸出

发布:使对象能够在当前作用域之外的代码中使用. 逸出:某个不应该发布的对象被发布(内部状态逸出 例子,隐式this引用逸出 例子)

不要在构造函数中使this引用逸出(这样就发布了一个尚未构造完成的对象)

线程封闭

在线程间不共享数据,仅在单线程内访问数据(例如线程池(一个Connection对象不会被多个线程访问))

  • Ad-hoc
  • 栈封闭, 只通过局部变量才能访问的对象.(任何方法都无法获取基本类型的引用,所以Java语言的这种语义确保了基本数据类型的局部变量始终封装在线程内)
  • ThreadLocal

不变性

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

满足以下条件的对象是不可变的:

  • 对象创建以后其状态就不能修改
  • 对象的所有域都是final类型
  • 对象是正确创建的(在对象的创建期间,this引用没有逸出)

final域

在Java内存模型中,final域有特殊的语义,final域能确保初始化过程的安全性,从而可以不受限制的访问不可变对象,并在共享这些对象时无需同步

使用volatile类型来发布不可变对象

将所有域封装进一个对象,并全部声明为final域

安全发布

不正确的发布对象: 使正确的对象被破坏.

不可变对象与初始化安全性

Java内存模型为不可变对象的共享提供了一种特殊的初始化安全性保证

安全发布的常用模式

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

  • 在静态初始化函数中初始化一个对象引用(静态初始化有JVM在初始化阶段执行,JVM内部存在同步机制,所以可安全发布,例如:public static Holder holder = new Holder(42))
  • 将对象的引用保存到volatile类型的域或者AtomicReference对象中
  • 将对象的引用保存到某个正确构造对象的final类型域中
  • 将对象的引用保存到一个由锁保护的域中

事实不可变对象

如果对象在技术上看是可变的,当其状态在发布后不会改变,则为 事实不可变对象.

在没有额外的同步的情况下,任何线程都可以安全的使用被安全发布的事实不可变对象.

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

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

第四章 对象的组合

使用现有线程安全组件组合为更大规模的组件或者程序

设计线程安全类的三要素

  • 找出构成对象状态的所有变量
  • 找出约束状态变量的不变性条件
  • 建立对象状态的并发访问策略

不变性条件,后验条件,先验条件:

不变性条件,指状态在任何时刻都必须满足的条件(例如在表示范围的类NumberRange中的不变性条件为:下界值 <= 上界值)

实例封装

将数据封装在对象内部,可以将数据的访问限制在对象的方法上,从而更容易确保线程在访问数据时总能持有正确的锁

被封闭对象一定不能超过它们既定的作用域.

封闭机制更易于构造线程安全的类.

Java监视器模式

即通过封闭原则将所有可变状态都封装起来,并有对象自己的内置锁来保护.(自始至终都使用该锁对象)

示例:车辆追踪

线程安全的委托性

当从头开始构建一个类时,多个非线程安全的类组合为一个类时,Java监视器模式非常有用,但是如果类中各个组件都已经是线程安全的了,那么是否需要再增加一个额外的线程安全层,则需要 视情况而定.

独立的状态变量

在一个类中,若所有的变量都是彼此独立的,并不会在其包含的多个状态变量上增加不变性条件,则可以该类的安全性委托给多个状态变量.

委托失效

在一个类中,若变量并不是彼此独立的,在其包含的多个状态变量上增加了不变性条件,或者后验条件等,则需要一个额外的线程安全层来保证类的线程安全.

发布底层状态

当线程安全性委托给了线程某个对象的底层状态变量时,在什么条件下可以发布这些变量,从而使其他类能修改它们.取决与在类中对这些变量施加了哪些不变性条件.

发布可变对象: 深度复制(快照)或者发布对象后,封装必要的原子操作. 发布不可变对象: 直接发布,依然线程安全.

在现有的安全类中添加功能

需要在同一个锁上进行扩充

组合

实现相同的接口,大部分操作委托给持有的对象进行,并且所有的操作都使用同一个锁进行.

同步策略文档化

基础构建模块

同步容器类

同步容器类类问题

同步容器类都是线程安全的,但是一些复合操作会破坏它的线程安全性(例如”若没有则添加”).使用客户端加锁的方式,可以确保线程安全性

迭代器与ConcurrentModificationException

每一个迭代器包含一个创建时保存的集合当时的变化计数器,若在迭代器遍历期间, 迭代器的变化计数器的值与集合的变化计数器的值不一致,则抛出ConcurrentModificationException(fail-fast及时失败机制),可通过对容器加锁来保证安全性,若不想对容器加锁,则可”克隆”整个容器进行迭代(副本封闭在线程内,不会有其他线程对其进行修改,所以迭代时无需对容器加锁,但克隆时仍需加锁, 且克隆容器存在显著性能开销,好坏取决与多个因素,包括容器大小,在每个元素上执行的工作,迭代操作相对于其他操作的调用频率,以及在响应时间和吞吐量等方面的需求).

隐藏迭代器

许多隐藏的方法都会对容器进行迭代,例如 System.out.springln(), hashCode(), equals() 等都会对容器进行迭代

并发容器

ConcurrentHashMap

  • 分段锁
  • size()返回近似值

额外原子Map操作

ConcurrentHashMap 不能被加锁来执行独占访问,无法通过客户端加锁来创建新的原子操作,但ConcurrentHashMap已提供了大量复合操作, putIfAbsent(K, V), replace(K, V), remove(K, V)等

CopyOnWriteArrayList

每当修改容器时都会复制底层数组.

阻塞队列和生产者-消费者模式

BlockingQueue

(SynchronousQueue也是一种BlockingQueue, 实际上它不是一个真正的队列)

桌面搜索示例

串行线程封闭

双端队列与工作密取

Work Stealing模式(ForkJoinPool框架), 减少竞争, 增加吞吐量, 一个线程的任务执行完毕后,会从另一个线程的待执行任务队列尾部窃取任务执行

阻塞方法与中断方法

  • 传递InterruptException
  • 恢复中断

同步工具类

  • 信号量(Semaphore): 不可从入的加锁语义(可用于实现资源池,例如数据库连接池)
  • 栅栏(Barrier): 类似与闭锁, 关键区别在于,所有线程必须同时到达栅栏位置,才能继续执行.闭锁用于等待事件,而栅栏用于等待其他线程, 栅栏可重用,闭锁不行. 例子,使用CyclicBarrir协调细胞自动衍生系统计算, Exchanger也是一种栅栏,是一种两方(two-partty)栅栏,例如一个线程向缓冲区写数据,另一个线程从缓冲区读数据
  • 闭锁(Latch): CountDownLatch, FutureTask也可用做闭锁,

构建高效且可伸缩的结果缓存

2-5章小结

  • 可变状态至关重要, 所有的并发问题都可以归结为 如何协调对并发状态的访问, 可变状态越少就越容易确保线程安全性.
  • 尽量将域声明为final类型,除非它们是可变的
  • 不可变对象一定是线程安全的

第六章 任务执行

在线程中执行任务

找出任务边界(使得各个任务是相互独立的,任务并不依赖与其他任务的状态,结果或者边界效应.)

串行地执行任务

服务器利用率低,当单线程在的等待I/O操作完成时,CPU将处于空闲状态

显示的为任务创建线程

为每一个任务创建一个线程来执行任务(不要这么做)

无限制创建线程的不足

  • 线程生命周期的开销非常高, 线程的创建以及销毁的代价.
  • 资源消耗, 活跃的线程会消耗系统资源,尤其是内存. 可运行数量多余可用处理器数量,那么闲置的线程,会占用许多内存.大量线程竞争CPU资源时还会产生其他的性能开销.
  • 稳定性, 不同的平台对线程的创建数量是有限制的.如果破坏了线程,很可能会OOM.

在一定范围内增加线程,可提高系统吞吐量.如果超出了这个范围,反而会降低程序执行速度,甚至导致整个应用程序崩溃.

Executor 框架

提供了对线程生命周期的支持,以及统计信息收集,应用程序管理机制和性能监视等机制. Executor 框架基于 生产者-消费者模式.

执行策略

即: What, where, When, How

  • 在什么(What)线程中执行任务
  • 任务按照什么(What)顺序执行(FIFO,LIFO,优先级)
  • 有多少(How Many)个任务能并发执行
  • 在队列中有多少(How Many)个任务在等待执行
  • 如果系统由于过载而需要拒绝一个任务,那么应选择哪一个(Which)任务?另外,如何(How)通知应用程序有任务被拒绝了?
  • 在执行一个任务之前或者之后, 应该进行哪些(What)动作

线程池

  • newFixedThreadPool
  • newCachedThreadPool
  • newSingleThreadPool
  • newScheduledThreadPool

Executor 生命周期

JVM只有在所有(非守护)线程全部终止后才会退出,所以,如果无法正确的关闭Executor,那么JVM将无法结束.

ExecutorService 的生命周期有三种状态: 运行, 关闭 和 已终结.

延迟任务与周期任务

*Timer存在的缺陷?*, (线程泄露, 并且执行所有定时任务只会创建一个线程,如果某个任务执行时间过长,将影响其他TimeTask的定时精确性)

DelayQueue实现了BlockingQueue.为ScheduledThreadPool提供调度功能.

找出可利用的并行性

找出任务边界,并发执行(例如,单个客户请求)

携带结果的任务 Callable 和 Future

Runable 和 Callable描述的都是抽象的计算任务

Future 标示一个任务的生命周期,并提供相应方法判断是否完成或者取消,以及获取任务的结果和取消任务等.

Executor 执行的任务的4个生命周期阶段:创建, 提交, 开始和结束

在异构任务并行化中存在的局限

只有当大量互相独立且 同构 的任务可以并发进行处理时,才能体现出将程序的工作负载分配到多个任务中带来的真正性能提升

CompletionService:Executor 与 BlockingQueue

CompletionService.get() 只会拿到以及完成的任务的结果(Future).

为任务设置时限

若无法在执行时间内完成的任务,可能不再需要其结果,则应对任务设置时限,防止其超时后仍继续执行(浪费资源).

小结

围绕任务执行设计应用程序,可简化开发过程,有助于实现开发.Executor框架将任务的提交与执行策略解耦,同时支持多种不同类型的执行策略.要想应用程序分解为不同任务时获得最大的好处,需定义清晰的任务边界.

第七章 取消与关闭

Java不提供抢占式的方式来停止线程, 只提供协作机制的中断. (*Thread.stop 和 suspend的缺陷?*)

任务取消

外部代码能在某个操作正常完成之前将其置入”完成”状态,那么这个操作就可以称为可取消的(Cancellable).(取消原因: 用户请求取消,应用程序事件,错误,关闭等) 一个可取消的任务必须包含取消策略,策略需详细定义取消操作的”How”(如何请求取消任务),”When”(任务何时检查是否已经请求了取消),”Where”(响应取消请求时应执行哪些操作)

中断

(*BlockingQueue中put方法是如何响应中断的? Thread.sleep 和 Object.wait 如何检查线程何时中断?*)

对中断操作的正确理解:它并不会真正的中断一个正常运行的线程,而只是发出中断请求.然后由线程在下一个合适的时刻中断自己(取消点).(例如wait,sleep,join等方法都会严格处理这种请求,发现中断状态时,将抛出一个异常)

在Java的API或者语言规范中,并没有将中断与任何取消语义关联起来,但实际上,如何在取消之外的其他操作中使用中断,那么都是不合适的,并且很难支撑器更大的应用.(所以使用中断的意义在于取消操作咯?). 通常中断是实现取消语义的最合理方式.

阻塞库方法,例如Thread.sleep和Object.wait等,在响应中断时执行的操作包括:清除中断状态,抛出InterruptedException,表示阻塞操作由于中断而提前结束了.

### 中断策略 最合理的中断策略是某种形式的 线程级 取消操作或者 服务级 取消操作.(例如,尽快退出,必要时进行清理,由或者暂停服务,重新开始服务等.)

如果除了将InterruptedException传递给调用者外还需要执行其他操作,那么应该在捕获InterruptedException之后恢复中断状态: Thread.currentThread().interrupt()

Java没有提供抢占式中断策略,然而通过推迟中断请求的处理,可以定制更加灵活的中断策略,从而使应用程序在响应性和健壮性之间实现合理的平衡.

响应中断

只有实现了线程中断策略的代码才可以屏蔽中断请求.在常规的任务和库代码中都不应该屏蔽中断请求. *在中断线程前,应该了解它的中断策略(否则可能造成异常情况)*

处理中断函数(例如Thread.sleep或者BlockingQueue.put等),有两种使用策略可用于处理InterruptedException:

  • 传递异常.使得你的方法也成为可中断方法.
  • 恢复中断状态.从而使调用栈中的上层待啊能够对其进行处理.

通过Future实现取消

处理不可中断的阻塞

常见不可中断阻塞

  • java.io包中的同步 Socket I/O
  • java.io包中的同步I/O
  • Selector的异步I/O
  • 获取某个锁 (如果等待某个内置锁在阻塞,那么将无法响应中断.)

可通过改写 interrupt方法将非标准的取消操作封装在Thread中.

采用 newTaskFor 封装非标准取消

基于线程的服务

正确的封装原则:除非拥有某个线程,否则不能对该线程进行操控(例如中断线程或者修改线程优先级等)

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

“毒丸”对象.

用来关闭生产者-消费者服务的一种方式,其含义是:当得到这个对象时,立即停止(只有在无界队列中,毒丸对象才能可靠的工作,否则也可能会永久阻塞.)

shutdownNow的局限性

方法会尝试取消正在执行的任务,并返回所有已提交但尚未开始的任务.( *但是无法知道执行了一半的那些任务* )

处理非正常的线程终止

可通过添加 UncaughtExceptionHandler 来处理.

JVM 关闭

关闭钩子

可通过 Runtime.addShutdownHook() 来添加.

守护线程

应尽可能少的使用守护线程(JVM停止时,会抛弃仍然存在的守护线程, *finally块也不会执行*).

终结器

*避免使用终结器*

第八章 线程池的使用

在任务线程与执行策略之间的隐性耦合

下面的任务类型都是需要指定其执行策略的:

  • 依赖性任务: 提交给线程池的任务,依赖与别的任务的结果或者执行,那么就给执行策略带来了约束

  • 使用线程封闭机制的任务: 即 使用单线程Executor将任务串行化执行,则可以放宽代码对线程安全的要求.(这样任务就与执行策略耦合在一起了,任务要求执行策略必须是单线程串行执行的,不然就会时区线程安全性)

  • 对响应时间敏感的任务: 例如限时返回,提高Executor管理的服务的响应性

  • 使用ThreadLocal的任务: 不应该使用TreadLocal在线程间传递值,应将线程本地值的生命周期只受限与任务的生命周期.

当任务都是同类型并且互相独立的,线程池的性能才能达到最佳, 如果任务需要特殊的定制执行策略,则应将其文档化,便于后期维护

线程饥饿死锁

即,所有正在执行任务的线程都由于等待其他仍处于工作队列中的任务而阻塞

下面的RenderTask页面渲染的任务就会因为等待header和footer的执行,而导致饥饿死锁.

    public class ThreadDeadLock {

        private ExecutorService executorService = Executors.newSingleThreadExecutor();

        public class RenderTask implements Callable<String> {
            @Override
            public String call() throws Exception {
                return renderPage();
            }

            private String renderPage() throws Exception {
                Future<String> header = executorService.submit(new LoadFileTask("header.html"));
                Future<String> footer = executorService.submit(new LoadFileTask("footer.html"));

                String page = renderBody();

                return header.get() + page + footer.get();

            }
        }

    }

运行时间较长的任务

使用限制任务等待资源时间的方式,提高整体的响应性

设置线程池的大小

若是 计算密集型的任务,则线程个数设置为N+1较好(N为CPU个数Runtime.getRunTime().getAvailableProcessors()),

I/O密集型或者其他阻塞操作的任务,由于线程不会一直执行,所以线程池的大小应该更大一些.具体大致为(使CPU达到期望的使用率):

Threads= N * U * (1 + W/C)

其中 N为CPU个数, U为CPU利用率(即想让当前线程池占用多少比例的CPU使用), W/C 表示等待时间W和计算时间C的比例

影响线程池大小的资源,其实不止CPU周期,还包括内存,文件句柄,套接字句柄和数据库连接等.计算这些资源对线程池的约束条件: 计算每个任务对该资源的需求量, 然后用该资源的可用总量除以每个任务的需求量,所得结果就是线程池的大小上线.

配置 ThreadPoolExecutor

使用 ThreadPoolExecutor 可配置线程池的执行策略(核心线程个数,最大线程个数,闲置线程存活时间,任务队列,任务拒绝处理器等),除了在构造时配置,也可在运行时通过对应方法配置.

线程的创建与销毁

newFixedThreadPool工厂方法 基本线程个数,最大线程个数在参数中指定,并且创建的线程池不会超时.

newCachedThreadPool工厂方法将线程的基本线程数设置为0,并且最大线程数设置为Integer.MAX_VALUE,超时时间设置为1分钟,这样的线程池可以被无限扩展,并且 当需求降低时会自动收缩.

管理队列任务

基本的3种任务排队队列:

  • 无界队列
  • 有界队列
  • 同步移交(synchronous Handoff) , 即:将一个元素放入SynchronousQueue时,必须有另外一个线程在等待接收这个元素,若没有空闲线程也不能创建新线程来接收元素,则根据饱和策略拒绝任务(所以它不是一个真正的队列)

newFixedThreadPool和newSingleThreadPool默认使用无界的LinkedBlockingQueue队列,更加稳妥的方式是使用有界队列(ArrayBlockingQueue,有界的LinkedBlockingQueue,PriorityBlockingQueue等)

饱和策略

  • AbortPolicy 拒绝任务,抛出 RejectExecutionException
  • CallerRunsPolicy 使用提交任务的线程,执行任务
  • DiscardPolicy 直接丢弃任务
  • DiscardOldestPolicy 丢弃最旧的任务(优先队列会抛弃掉优先级最高的队列, 所以优先队列最好不要和这个策略一起用)

线程工程

ThreadFactory接口,可定制新线程

使用Executor中的privilegedTreadFactory 可在程序中需要使用安全策略来控制特殊代码库的访问权限时使用(_这个是指什么?_)

在构造函数后再定制 ThreadPoolExecutor

如果不想外部代码这么做, 可使用 Executors 的 unconfigurableExecutorService 工程方法进行包装.(newSingleThreadPool 就是这么做的, 因为它不能让外部线程改变它的线程数量,否则就不是单线程执行器了)

扩展 ThreadPoolExecutor

  • beforeExecute 方法
  • afterExecute 方法
  • teminated 方法

递归算法并行化

线程池使用例子

第十章 避免活跃性危险

安全性与活跃性之间同城存在着某种制衡.使用加锁机制确保线程安全,但是过度使用会导致活跃性故障(顺序性死锁,资源死锁等)

死锁

哲学家进餐问题

锁顺序死锁

如果所有线程已固定的顺序来获得锁,那么在程序中就不会出现锁顺序死锁的问题.

动态的锁顺序死锁

即 加锁局部变量一致,但依赖于参数传递顺序

在协作对象之间发生的死锁

在一个方法中获取了一个锁对象,在未释放锁的情况下,调用其他对象方法又需要去获得另外的锁, 则可能发送协作对象之间的死锁

如果在持有锁时调用某个外部方法,那么将出现活跃性问题,在这个外部方法中可能会获取其他锁(这可能产生死锁),或者阻塞时间过长,导致其他线程无法及时获得当前被持有的锁.

开发调用

即:在调用某个方法时,不需要持有锁.(使得原有的原子操作变为了非原子性操作,视具体情况决定是否可丢弃原子性)

资源死锁

例如等待获取资源池中的资源,一个线程拿到资源1,等待资源2, 另一个线程拿到资源2,等待资源1

又或是 线程饥饿死锁.如果某些任务需奥等待其他任务的结果,那么这些任务往往是产生线程饥饿死锁的主要来源,有界线程池/资源池与互相依赖的任务不能一起使用(有可能队列已满,等待的任务无法执行,导致无限期等待.)

死锁的避免与诊断

尽量减少潜在的加锁交互数量,将获取锁时需要遵循的协议写入正式文档并始终遵循这些协议

细粒度锁的程序中,可使用 两阶段策略 : 首先找出什么地方将获取多个锁(使这个集合尽量小),然后对所有这些实例进行全局分析,从而确保它们在整个程序中获得锁的顺序都保持一致

支持定时的锁

使用支持定时的锁(例如 Lock.tryLock)代替内置锁机制,可检测死锁和从死锁中恢复过来.(多层tryLock,照样死锁)

通过线程转储信息来分析死锁

JVM通过线程转储(Thread 啊 Dump)来帮助识别死锁的发送.线程转储包括各个运行中的线程的栈信息,这类似与发生异常时的栈追踪信息(包括加锁信息,例如哪些栈帧获得了哪些锁,被阻塞的线程在等待哪一个锁.)

其他活跃性危险

饥饿

线程由于无法获得它所需要的资源而不能继续执行时,就发生了”饥饿”.引发饥饿的最常见资源就是CPU时钟周期.

要避免使用线程优先级.因为这会增加平台依赖性,并可能导致活跃性问题.在大多数并发应用程序中,都可以使用默认的线程优先级.

糟糕的响应性

应降低后台竞争用户请求线程所需资源的线程的优先级.

活锁

线程由于不断重复执行相同操作,且总会失败.活锁通常发生爱处理事务消息的应用程序中: 如果不能成功的处理某个消息,那么消息处理机制将回滚整个事务,并将它重新放入队列头,但是消息处理会始终失败. 这个通常是由于过度的错误恢复代码造成的.因为它错误的将不可修复的错误作为可修复的错误了.

还有,当多个互相协作的线程都会彼此进行响应从而修改各自状态,并使得任何一个线程都无法继续执行时,就发生了活锁(就像两个人在独木桥上相遇,同时理让对方,但是却在另一条路上再次相遇了,从而如此反复避让下去.),解决这个问题,可以在重试机制中引入 随机性 来处理.

第十一章 性能与可伸缩性

线程的最主要目的是提高程序的运行性能.

对性能的思考

提升性能意味着用更少的资源做更多的事情.资源密集型操作(CPU密集型,数据库密集性.)

多线程比单线程多的额外开销操作,包括:线程之间的协调(例如加锁,触发信号以及内存同步等),增加的上下文切换,线程的创建和销毁,以及线程的调度等.(如果过度使用线程,这些开销可能导致多线程比单线程的吞吐量,响应性更低.)

通过并发来获得更好的性能:1.更加有效的利用现有的处理资源. 2,在出现新的处理资源时使程序尽可能的利用这些新资源.

性能与可伸缩性

程序有两个方面:

  • 多快 : 即 程序的 “运行速度”,某个指定任务单元处理完成所需时间.
  • 多少 : 即 在计算资源一定的情况下,能完成”多少”任务

对服务器应用程序来说,:”多少”这个方面(可伸缩性,吞吐量和生产量) 往往比”多块”这个方面更受重视.(在交互式程序中,延迟或许更加重要.)

可伸缩性指的是:当增加计算资源时(例如CPU,内存,存储器容量或者I/O带宽),程序的吞吐量或者处理能力能相应的增加.

MVC模型的 表现层,业务逻辑层,持久化层彼此独立,并可能由不同的系统来处理.这很好的说明了 提高可伸缩性 通常会造成 性能损失 的原因.

评估各种性能权衡因素

避免不成熟的优化.首先使程序正确,然后在提高运行速度 - 如果它还不够快的话

决策时,有时会通过增加某种形式的成本来降低另一中形式的开销(例如,增加内存使用量以降低服务时间),也会通过增加开销来换取安全性.在许多优化措施中带来的安全性和可维护性等风险非常高.

在使某个方案比其他方案”更快”之前,首先问自己一些问题:

  • “更快”的含义是什么?
  • 该方法在什么条件下运行得更快?在低负载还是高负载的情况下?大数据集还是小数据集?能否通过测试结果来验证你的答案?
  • 这些条件在运行环境中的发生频率?能否通过测试结果来验证你的答案?
  • 在其他不同条件的环境中能否使用这些代码?
  • 在实现这种性能提升时需要付出哪些隐含的代价,例如增加开发风险或维护开销?这种权衡是否合适?

_以测试为基准,不要猜测_

Amdahl定律

Amdahl定律描述的是:在增加计算资源的情况下,程序在理论上能够实现最高加速比,这个值取决与程序中可并行组件与串行组件所占的比重,假定F是必须被串行执行的部分,那么更具Amdahl定律,在包含N个处理器的机器中,最高加速比为:

    SpeedUp <= 1 / (F + (1-F)/N)

随着处理器数量的增加,可以很明细的发现,即使串行部分所占的百分比很小,也会极大的限制当增加计算资源时能够提升的吞吐率.

_所有的并发程序都包含一些串行执行部分,如果认为程序中不存在串行部分,那么可以再自己的检查一遍_

在各种框架中隐藏的串行部分

SychronizedLinkedList 和 ConcurrentHashMap 吞吐量比较.

Amdahl定律的应用

锁分段(把一个锁分解为多个锁) 比 锁分解(将一个锁分解为两个锁) 似乎更有前途,因为分段的数量可以随着处理器数量的增加而增加.(性能优化应考虑性能需求,在某些情况下,将一个锁分解为两个就够了)

线程引入的开销

对于为了提升性能而引入的线程来说,并行带来的性能提升必须超过并发导致的开销.(单线程程序即不存在线程调度,也不存在同步开销.)

上下文切换

在JVM和操作系统的代码中消耗越多的CPU时钟周期,应用程序的可同CPU时钟周期就越少.(当一个新的线程被切换进来时,它所需要的数据可能不在当前处理器的本地缓存中,因此上下文切换将导致一些缓存缺失,因而线程在首次调度运行时会更加缓慢)

vmstat命令能报告上下文切换次数以及在内核中执行时间所占比例等信息。如果内核占用率较高(10%),那么通常表示调度活动发生得很频繁,这很可能是由于I/O或者竞争锁导致的阻塞引起的。

内存同步

内存栅栏(提供的可见性保证中可能用的到的特殊指令),它可以刷新缓存,使缓存无效,刷新硬件的写缓存,以及停止执行管道。内存栅栏可能同样会对性能带来间接的影响,因为它们将抑制一些编译器优化操作,在内存栅栏中,大多数操作都是不能被重排序的。

一些完备的JVM能通过逸出分析(Escape Analysis)找出不会发布到堆的本地对象引用。从而可去除其上的加锁操作(锁消除优化)。又或者编译器执行锁粒度粗化等操作。

_不要过度担心非竞争同步带来的开销,这个基本的机制已经非常快了,并且JVM还能进行额外的优化以进一步降低或者消除锁,因此,我们应该将优化重点放在那些发生锁竞争的地方。_

阻塞

被阻塞的现场在其执行时间片还未用完之前就要被交换出去,而在随后当要获取的锁或者其他资源可用时,又再次被切换回来。(由于锁竞争而导致阻塞时,线程在持有锁时将存在一定的开销:当它释放锁时,必须告诉操作系统恢复阻塞的线程)

减少锁的竞争

_在并发程序中,对可伸缩性的最主要威胁就是独占方式的资源锁_

两个因素将影响在锁上发生竞争的可能性:锁的请求频率,以及每次持有该锁的时间.

三种降低锁的竞争程度的方式:

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

缩小锁的范围(“快进快出”)

同步需要一定的开销,当把一个同步块分解为多个同步块的时候,可能反而会对性能产生负面影响.在分解同步代码块时,理想的平衡点将与平台相关,但在实际情况中,仅当可以将一些”大量”的计算或者阻塞操作从同步块中移出时,才应该考虑同步代码块的大小.

减小锁的粒度

降低线程请求锁的频率.可通过锁分解(不同的条件,可由不同的锁来守护)和锁分段等技术来实现.

如果对竞争并不激烈的锁进行分解,那么在性能和吞吐量等方面带来的提升将非常有限,但也会提高性能随着竞争提高而下降的拐点值.对竞争适中的锁进行分解时,实际上是把这些锁转变为非竞争的锁,从而有效的提高性能和可伸缩性.

锁分段

ConcurrentHashMap的实现即为锁分段, _包含16个(默认16个)锁的数组_

锁分段的一个劣势在于:与采用单个锁来实现独占访问相比,要采用多个锁来实现独占范围将更加困难并且开销更高(ConcurrentHashMap.size() 如果不加锁获取每个segment大小失败,则需对所有segment加锁,然后获取其大小,并相加)

避免热点域

当每个操作都请求多个变量时,锁的粒度将很难降低,这是在性能与可伸缩性之间相互制衡的另一个方面,一些常见的优化措施,例如将一些反复计算的结果缓存起来,都会引入一些”热点域”,而这些热点域往往会限制可伸缩性.

一些代替独占锁的方法

第三种降低竞争锁的影响的技术就是放弃使用独占锁,从而有助于使用一种友好并发的方式来管理共享状态,例如,使用并发容器,读-写锁(ReadWriteLock),不可变对象,以及原子变量(使用底层并发原语CAS降低更新”热点域”的开销,提高可伸缩性)

检测CPU利用率

vmstat 和 mpstat 的使用

如果CPU没有得到充分利用,通常是以下几个原因:

  • 负载不充分(请求量不够)
  • I/O密集 (可通过iostat判断应用程序是否是磁盘I/O密集型的)
  • 外部限制(依赖于外部服务,例如数据库或者Web服务,等待外部服务的结果)
  • 锁竞争 (线程转储中会出现 “waiting to lock monitor…”)

对对象池说”不”

通常,对象分配操作的开销比同步的开销更低.(对象池需要同步来保证正确性)

Map性能比较

ConcurrentHashMap, ConcurrentSkipHashMap, Synchronized HashMap, Synchronized TreeMap

减少上下文切换的开销

在许多任务中都包含一些可能被阻塞的操作,当任务在运行和阻塞这两个状态之间转换时,就相当于一次上下文切换.

小结

由于线程常常是为了充分利用多个处理器的计算能力,因此在并发程序性能的讨论中,通常更多的将侧重点放在 吞吐量可伸缩性 上, Amdahl定律说明,程序的可伸缩性取决于在所有代码中必须被串行执行的代码比例.因为java程序中串行操作的主要来源是独占方式的资源锁,因此通常可以通过以下方式来提升可伸缩性:减少持有锁的时间,降低锁的粒度,以及采用非独占的锁或者非阻塞锁来代替独占锁.

并发程序的测试

测试并发程序所面临的挑战在于:潜在错误的发生并不具有确定性,而是随机发生的.

在第一章中,安全性定义为:”不发生任何错误的行为”, 活跃性定义为:”某个良好的行为终究会发生”

与活跃性测试相关的是性能测试,性能测试可以通过多个方面来衡量,包括:

  • 吞吐量:指一组并发任务中已完成任务所占的比例.
  • 响应性:指请求从发出到完成之间的时间(也称为延迟)
  • 可伸缩性: 指在增加更多资源的情况下(通常是CPU),吞吐量(或者缓解短缺)的提升情况

正确性测试

首先和测试串行类一样执行相同的分析-找出需要检查的不变性条件和后验性条件.测试集中包含一组串行测试通常是有帮助的,因为它们有助于在开始分析数据竞争之前就找出与并发性能无关的问题.

基本单元测试

即:串行测试,子开始分析数据竞争之间就找出与并发性无关的问题.

对阻塞操作的测试

安全性测试

在构建对并发类安全性测试中,需要解决的关键问题在于,要找出那些容易检查的属性,这些属性在发生错误的情况下极有可能失败,同时又不会使得错误检查代码人为的限制并发性.理想情况是,在测试属性中不需要 *任何的同步机制*

资源管理的测试

测试的另一个方面是要判断类中是否没有做它不应该做的事情,例如资源泄露.(可使用一些堆分析工具用来测试出对内存的不合理占用)

使用回调

产生更多的交替操作

有一种有用的方法可以提高交替操作的数量,以便能更有效地搜索程序的空间状态:在访问共享状态的操作中,使用Thread.yield将产生更多的上下文切换(这个技术与平台有关,JVM可以将Thread.yield作为一个空操作.可使用较短时间的sleep来替代,虽然慢些,但更加可靠).

性能测试

性能测试将衡量典型测试用例中的端到端性能.性能测试的第二个目标是根据经验值来调整各个不同的限值,例如线程数量,缓存容量等(这些限值可能依赖与具体平台的特性,例如处理器的类型,处理器的步进级别Stepping Level, CPU的数量以及内存大小等.所以需要动态配置,从而使程序更加良好的运行).

*线程过多,会导致大部分时间都消耗在线程的阻塞和解除阻塞等操作上,反而会降低吞吐*

避免性能测试的陷阱

垃圾回收

垃圾回收的执行时序是无法预测的.所以垃圾回收会对测试结果产生偏差.避免这种情况的两种策略:第一种策略是,确保垃圾回收操作在测试运行的整个期间都不会执行(可以在调用JVM时指定 -verbose:gc 来判断是否执行了垃圾回收操作).第二种策略是,确保垃圾回收操作在测试期间执行多次,这样测试程序就能充分反应出运行期间的内存分配与垃圾回收等开销.通常第二种策略更好,它要求更长的测试时间,并且更有可能反应实际环境下的性能.

动态编译

JVM中将字节码的解释与动态编译结合起来使用.当某个类第一次被加载时,JVM会通过解译字节码的方式来执行它.在某个时刻如果一个方法的运行次数足够多,那么动态编译器会将它编译为机器代码,编译完成后,代码的执行方式将从解释执行变成直接执行.

有一种方式可以防止动态编译对测试结果产生偏差,就是使程序允许足够长的时间(至少数分钟),这样编译过程以及解释执行都只是总运行时间的很小一部分.另一种方法是使代码预先运行一段时间并且部测试这段时间内的代码性能,这样在开始计时前代码就已经被完全编译了.(HotSpot中可使用命令行选项 -xx:+PrintCompilation 在动态编译运行时输出一条信息,可通过这条信息验证动态编译时在测试运行前,而不是运行过程中执行的)

对代码路径的不真实采样

测试程序不仅要大致判断某个典型应用程序的使用模式,还需要尽量覆盖在该应用程序中执行的代码路径集合.否则动态编译器可能会针对一个单线程的测试程序进行一些专门的优化.但只要在真实的应用程序中略微包含一些并行,都会使这些优化不复存在.

不真实的竞争程度

要获得有实际意义的结果,在并发性能测试中应该尽量模拟典型应用程序中的线程本地计算量以及并发协调开销.如果在真实应用程序的各个任务中执行的工作,与测试程序中执行的工作截然不同,那么测试出的性能瓶颈位置将是不准确的.

无用代码消除

如果幸运的话,编译器将删除整个程序中的无用代码,从而得到一份明显虚假的测试数据.但如果不幸运的话,编译器在消除无用代码后将提高程序的执行速度,从而使你做出错误的结论.(在HotSpot中,许多基准测试在”-server”模式下都能比在”-client”模式下运行的更好.)

*要编写有效的性能测试程序,就需要告诉优化器不要将基准测试当做无用代码而优化掉.这就需要在程序中对每个计算结果都要通过某种方式来使用,这种方式不需要同步或者大量的计算.*

不仅每个计算结果都应该被使用,而且还应该是不可预测的.否则,一个只能的动态优化编译器将用预先计算的结果来代替计算过程.

其他的测试方法

代码审查

review代码

静态分析工具

findBugs等

面向切面的测试计算

使用AOP技术来确保不变性条件不被破坏,或者与同步策略的某些方面保持一致.

分析与监测工具

JMX提供了一些有限的功能来监测线程的行为.

第十三章 显示锁

Lock 与 ReentrantLock

Lock的实现中提供与内部锁相同的内存可见性,但在加锁语义,调度算法,顺序保证以及性能特性等方面可以有所不同.

lock.lock , 与 synchronized一样,不响应中断,若有其他线程调用等待线程的 Thread.interrupt() , 则线程会继续等待.

lock.lockInterruptibly , 响应中断, 若有其他线程调用等待线程的 Thread.interrupt() ,则线程会做出响应 (ReentrantLock 会抛出 InterruptedException 异常)

轮询锁与定时锁

可定时的与可轮询的锁获取模式是由tryLock方法实现的.防止死锁的唯一方法就是在构造程序时避免出现不一致的锁顺序,可定时的与可轮询的锁提供了另一种选择:避免死锁的发生.

可中断的锁获取操作

lock.lockInterruptibly()

非块结构的加锁

降低锁粒度,分段锁

性能考虑因素

JDK6提升了sychronized的性能, 所以在JDK6中ReentrantLock与synchronized的性能差不多(JDK5中ReentrantLock比synchronized的性能更加优秀).

公平性

即按照获取锁的顺序,先到先得.(ReentrantLock的构造器提供两种公平性选择), 大多数情况下,非公平锁的性能要高于公平锁的性能(A线程持有锁,B线程请求锁,B被挂起,A线程释放锁,B线程被唤醒并执行,在B线程被完全唤醒之前,可能有C线程获取锁并使用以及释放这个锁,这是非公平锁可能出现的情况,所以非公平锁性能要好些.)

在 synchronized 和 ReentrantLock之间进行选择

仅当内置锁不能满足需求时,才可以考虑使用ReentrantLock.(将ReentrantLock作为一种高级工具,可定时,可轮询,可中断,公平队列,非块结构锁等需求时使用.)

*synchronized在线程转储中能获得比ReentrantLock更多的信息*

读写锁

ReadWriteLock

构建自定义的同步工具

状态依赖性的管理

并发对吸纳给上依赖状态的方法,可以使用等待前提条件变为真(轮询或休眠)

轮询与休眠的抉择: 要么容忍自旋导致的CPU时钟周期浪费,要么容忍由于休眠而导致的低响应性

实例 :将前提条件的失败传递给调用者

条件队列

它使得一组线程(称之为等待线程集合)能够通过某种方式来等待特定的条件变更成真.传统队列的元素是一个个数据,而条件队列中的元素是一个个正在等待相关条件的线程. 条件队列使得在表达和管理状态依赖性时更加简单和高效.

使用条件队列

条件谓词

要想正确的使用条件队列,关键是找出对象在哪个条件谓词上等待.条件谓词是使某个操作成为状态依赖操作的前提条件.

条件等待中存在一种重要的三元关系.包括 加锁,wait方法和一个条件谓词

过早唤醒

丢失信号

指:线程必须等待一个已经为真的条件.但在开始等待之前没有检查条件谓词,那么线程就会等待一个以及发生过的事件.

通知

notify比notifyAll更加危险, notifyAll可能比notify更低效,但却更容易确保类的行为是正确的.

显示的Condition对象

AQS

FutureTask

原子变量与非阻塞同步机制

非阻塞算法的优势:可以使多个线程在竞争相同的数据时不会发生阻塞,因此它能在粒度更细的层次上进行协调,并且极大的减少调度开销.

在非阻塞算法中不存在死锁和其他活跃性问题.

原子变量提供了与volatile类型变量相同的内存语义.

锁的劣势

激烈竞争,休眠的代价,在挂起和恢复线程等过程中存在着很大的开销,并且通常存在着较长时间的中断.(*存在哪些开销?*)

硬件对并发的支持

比较并交换

CAS

CAS的主要缺点是,它将使调用者处理竞争问题(通过重试,回退,放弃),而在锁中能自动处理竞争问题(线程在获得锁之前将一致阻塞)

CAS的最大缺陷是难以围绕CAS正确的构建外部算法

原子变量类

原子变量类是一种”更好的volatile”

它提供了比原始CAS更加丰富的操作

非阻塞栈

*实现*

非阻塞链表

*实现*

原子域更新器

ABA问题

可以通过引入 版本号 来解决

Java内存模型