《java并发编程实战》笔记(无代码)

文章目录

第一部分基础知识

二.线程安全性

无状态:不包含任何域,也不包含任何对其他类中域的引用。无状态的对象一定是线程安全的

竞态条件:当某个结果的正确性取决于多线程的交替执行时就会发生竞态条。最常见的就是先检查后执行的操作,在检查时可能会读取到一个失效值从而执行了错误的操作

线程安全性:当多个线程访问某个类时,这个类始终都能表现出正确的行为,那么就称这个类是线程安全的。在线程安全类中封装了必要的同步机制,因此客户端无需进一步同步措施

重入:某个线程请求一个由其他线程持有的锁时,发出请求的线程会阻塞。如果线程试图获取一个已经由它自己持有的锁,那么他发出的请求就会成功。

1.synchronized 是Java内置锁(内置锁可以重入,不然会发生死锁),有方法锁、对象锁、类锁区别

2.Java获取锁的操作粒度是线程而不是调用

3.Java内置锁简单用法

4.Java中的域,静态域,实例域及类的初始化顺序

三.对象的共享

可见性:一个线程修改了某个共享变量的值另一个线程可以立刻看到修改后的变量值

失效数据:在没有同步的情况下,一个线程修改了某个共享变量的值但是另一个读取该变量值的线程所读取的值却不是最新的值,读到的是一个失效的值。

最低安全性:线程间没有同步时,读取变量值可能会读到一个失效值,但是这个是失效值是之前某个线程设置的值,不是一个随机值,这种安全性保障称为最低安全性,它适用于绝大多数变量但是不适用用long、double变量

重排序:在没有同步时JVM和CPU及运行时可能对程序的执行顺序进行一些调整

发布对象:使对象可以在当前作用域以外的代码中使用。比如将一个对象的引用保存到其他代码可以访问的地方,或者在某一个非私有的方法中返回该引用,或者将引用传递到其他类的方法中

逸出:当某个不应该发布的对象被发布时,就称为逸出

1.volatile关键字 可以在变量值改变后将值存到主内存,使其他线程对变量的缓冲失效,从而达到可见性即在多线程中一个线程改变了变量的值其它线程可以立刻看见该变量的最新值,但是不能保证原子性

2.long、double变量是非volatile类型的64位数值变量,JVM在对其进行读或者写操作时会先把其分解为两个32位操作,所以不适用最低安全性,读取的值可能大可能小

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

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

3.1 不变性

不可变对象:为满足同步,使用不可变的对象(final)

1.当满足以下条件时,对象才是不可变的:

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

2.不可变对象一定时线程安全的
3.volatile发布不可变对象可以提供弱线程安全(volatile保障可见性,不可变对象保障弱原子性)

3.2 线程封闭

线程封闭:不在线程间共享数据的技术。可以使用ThreadLocal类、或者保证volatile变量只有一个线程执行写操作。即把对象封装在对应的线程内

1.ad-hoc线程封闭:这是完全靠实现者控制的线程封闭,他的线程封闭完全靠实现者实现。Ad-hoc线程封闭非常脆弱,没有任何一种语言特性能将对象封闭到目标线程上。

2.栈封闭:栈封闭是线程封闭的一种特例,是我们编程当中遇到的最多的线程封闭。多个线程访问一个方法,此方法中的局部变量都会被拷贝一分儿到线程栈中。所以局部变量是不被多个线程所共享的,也就不会出现并发问题。所以能用局部变量就别用全局的变量,全局变量容易引起并发问题。

3.使用ThreadLocal类,ThreadLocal可以使变量的值与线程关联,同一个变量不同的线程拥有不同的值。initialValue是用来获取初始值的方法(第一次调用ThreadLocal.get方法返回的值)

3.3 对象的发布

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

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

2.如果一个状态变量是线程安全的,并且没有任何不变性条件来约束它的值,在变量的操作:上也不存在任何不允许的状态转换,那么就可以安全地发布这个变量。

3.4 安全发布的常用模式

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

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

2.Java对象的安全发布

3.5 在线程安全容器内部同步

1.通过将一个键或者值放入Hashtable、synchronizedMap 或者ConcurrentMap中,可以安全地将它发布给任何从这些容器中访问它的线程(无论是直接访问还是通过迭代器访问)。

2.通过将某个元素放入Vector、 CopyOnWriteArrayList、 CopyOnWriteArraySet、 synchronizedList或synchronizedSet中,可以将该元素安全地发布到任何从这些容器中访问该元素的线程。

3.通过将某个元素放入BlockingQueue或者ConcurrentLinkedQueue中,可以将该元素安全地发布到任何从这些队列中访问该元素的线程。

3.6 在并发程序中使用和共享对象时的实用策略

1.线程封闭。线程封闭的对象只能由一个线程拥有,对象被封闭在该线程中,并且只能由这个线程修改。

2.只读共享。在没有额外同步的情况下,共享的只读对象可以由多个线程并发访问,但任何线程都不能修改它。共亲的只读对象包括不可变对象和事实不可变对象。

3.线程安全共享。线程安全的对象在其内部实现同步,因此多个线程可以通过对象的公有接口来进行访问而不需要进一步的同步。

4.保护对象。被保护的对象只能通过特有特定的锁来访问,保护对象包括封装在其他线程安全对象中的对象,以及已发布的并且由某个特定锁保护的对象。

四.对象的组合

4.1 设计线程安全的类

后验条件:判断某些状态的迁移是否有效。比如一个自增操作(i++),下一个值只能是上一个值加一的值

同步策略(SynchronizationPolicy):定义了如何在不违背对象不变条件或后验条件的情况下对其状态的访问操作进行协同。同步策略规定了如何将不可变性、线程封闭与加锁机制等结合起来以维护线程的安全性,并且还规定了哪些变量由哪些锁来保护。

类的不可变条件:用于判断状态是否有效。比如一个类中有一个long 值 那么类其中一个不变性条件是Long.MIN_VALUE<value<Long.MAX_VALUE

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

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

4.1.1 依赖状态的操作

前验条件:某些对象的方法中包含了一些基于状态的先验条件。比如不能从一个空队列中移除元素,在删除元素前队列必须是非空的。

依赖状态的操作:如果在某个操作中包含有基于状态的先验条件,那么该操作称为依赖状态的操作。

4.1.2 状态的所有权

状态的所有权:许多情况下,所有权与封装性总是相互关联的。对象封装它拥有的状态,反之也成立,即拥有它封装的状态的所有权。

1.状态变量的所有者将决定采用何种加锁协议来维持变量状态的完整性。所有权意味着控制权。然而,如果发布了某个可变对象的引用,那么就不再拥有独占的控制权,最多是”共享控制权“。对于从构造函数或者从方法中传递进来的对象,类通常并不拥有这些对象,除非这些方法是被专门设计为转移传递进来的对象的所有权。

2.容器类通常是一种所有权分离的形式,其中容器类拥有自身的状态,而客户代码则拥有容器中各个对象的状态。

4.2 实例封闭

Java监视器模式:把对象的所有可变状态都封装起来,并由对象自己的内置锁保护

实例封闭:通过确保对象只能由一个线程访问(线程封闭)或者通过一个锁来保护该对象的所有访问

1.私有锁对象的优点

  • 私有锁对象可以将锁封装起来,使客户代码无法得到锁,但客户代码才可以通过公有方法来访问锁,以便(正确或者不正确的)参与到它的同步策略中。
  • 要想验证某个公有访问的锁在程序中是否被正确的使用,则需要检查整个程序,而不是单个类,使用私有锁降低了验证的复杂度。

4.3 线程安全性的委托

线程安全性的委托:在某些情况下,通过线程安全类组合而成的类是线程安全的,称之为线程安全性的委托

委托失效:大多数组合对象存在着某些不变性条件。会导致委托失效,组合而成的类非线程安全。

1.大多数对象都是组合对象。当从头开始构建一个类,或者将多个非线程安全的类组合为一个类时,Java 监视器模式是非常有用的。但是,如果类中的各个组件都已经是线程安全的,有时会需要增加一个额外的线程安全层(组件间存在不变性条件),有时不用,要视情况而定。

4.3.1 活跃性问题

活跃性:指某件正确的事情最终会发生,当某个操作无法继续下去的时候,就会发生活跃性问题。在多线程中一般有死锁、活锁和饥饿问题

死锁:多个线程因为环形的等待锁的关系而永远的阻塞下去。

活锁:线程不断重复执行相同的操作,而且总会失败。当多个相互协作的线程都对彼此进行响应而修改各自的状态,并使得任何一个线程都无法继续执行(只能一直重复着响应和修改自身状态),就发生了活锁。如果迎面两个人走路互相让路,总是没有随机性地让到同一个方向,那么就会永远地避让下去。

饥饿:当线程无法访问它所需要的资源而导致无法继续时,就发生了饥饿。如一个线程占有锁永远不释放,等待同一个锁的其他线程就会发生饥饿。

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

1.最安全的方式是修改原始类,但是通常无法做到

2.扩展这个类,但是不是所有的类都可以扩展。因为同步策略实现被分布到多个单独维护的源代码文件中,如果底层的类改变了同步策略并选择了不用的锁来保护它的状态变量那么子类会被破坏(同步策略改变后,子类无法在使用正确的锁来控制对基类状态的并发访问)

3.扩展类的功能,但是不扩展类,添加辅助类(客户端加锁机制)。在原子操作上添加基类的锁。这种方法很脆弱,因为它将类A的加锁代码放到与A类完全无关的类中,而且会破坏同步策略的封装性。

4.组合,使用监视器模式封装了要使用的类,这样就不用关心到底使用哪个对象的锁,统一用封装类的锁

五.基础构建模块

5.1 同步容器类

1.Java中的同步容器包括2类

  • Vector、Stack、HashTable。他们是早期JDK的一部分
  • Collections类中提供的静态工厂方法创建的类。该类是通过将底层类的状态封装起来,并对每个公有方法进行同步来实现线程安全

2.同步容器类都是线程安全的,但是对于某些复合操作需要额外的加锁来保护。常见复合操作有:迭代(反复访问元素,直到遍历所有元素)、跳转(根据指定顺序找到当期元素的下一个元素)以及条件运算(如:如没有则添加)

3.同步容器缺陷

  • 并非任何场景都是线程安全的。
  • 将所有对容器状态的访问都串行化,以实现他们的线程安全性。严重降低了并发性,当多个线程竞争容器的锁时,吞吐量将严重降低

5.2 并发容器

并发容器是针对多个线程并发访问设计的,通过并发容器来代替同步容器,可以极大地提高伸缩性并降低风险

5.2.1 concurrentHashMap

concurrentHashMap采用了分段锁来实现更大程度的共享。在这种机制下,任意数量的读取线程可以并发地访问Map,执行读取操作的线程和执行写入操作的线程可以并访问Map,并且-定数量的写人线程可以并发地修改Map。ConcurrentHashMap带来的结果是在并发访问环境下将实现更高的吞吐量,而在单线程环境中只损失非常小的性能。

concurrentHashMap不能被加锁来执行独占访问,因此无法使用客户端加锁来创建新的原子操作。但是一些常见的复合操作已经实现比如‘如果没有则添加’(putIfAbsent)、‘若相等则移除’(remove),‘若相等则替换’(replace)

5.2.2 CopyOnWriteArrayList、CopyOnWriteArraySet

通过创建底层数组的新副本来实现所有可变操作(添加,设置等)。在每次修改时,都会创建并重新发布一个新的容器副本,从而实现可变性。它返回的其实是list的一个快照。仅当迭代操作远远多于修改时才使用该容器

每当修改容器时都会复制底层数组,这需要一定的开销。特别是当容器的规模较大时,所以一般当迭代操作远远多于修改时才使用该容器

1.优点:保证多线程的并发读写的线程安全

2.缺点:

  • 创建底层数组的新副本,会带来内存问题。如果实际应用数据比较多,而且比较大的情况下,占用内存会比较大,这个可以用ConcurrentHashMap来代替
  • CopyOnWrite容器只能保证数据的最终一致性,不能保证数据的实时一致性。所以如果你希望写入的的数据,马上能读到,请不要使用CopyOnWrite容器

3.CopyOnWrite并发容器用于读多写少的并发场景。

4.CopyOnWriteArrayList 使用入门源码详解

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

生产者:负责产生数据的模块

消费者:处理数据的模块

缓冲区:生产者-消费者模式需要有一个缓冲区处于生产者和消费者之间,作为一个中介。

1.如果生产者直接调用消费者的某个方法,由于函数调用时同步的,那么在消费者处理完数据之前,生产者就一直在等待。如果消费者的处理速度很快可能还好,如果消费者处理数据很慢,那么生产者就要长时间等待,而不能进行下一步的操作。

2.生产者把数据放入缓冲区,而消费者从缓冲区取出数据。这样可以降低生产者和消费者的耦合性,他们都依赖于某个缓冲区(队列)

3.使用了生产者-消费者模式之后,生产者不用关心消费者的处理能力,直接将数据放入缓冲区就好。

4.生产者-消费者模式主要就是为了处理并发问题。

5.在类库中包含了BlockingQueue的多种实现,其中,LinkedBlockingQueue 和ArrayBlockingQueue是FIFO队列,二者分别与LinkedList和ArrayList类似,但比同步List 拥有更好的并发性能。PriorityBlockingQueue 是一个按优先级排序的队列,当你希望按照某种顺序而不是FIFO来处理元素时,这个队列将非常有用。正如其他有序的容器一样,PriorityBlockingQueue 既可以根据元素的自然顺序来比较元素(如果它们实现了Comparable方法),也可以使用Comparator来比较。

6.SynchronousQueue,实际上它不是一个真正的队列,因为它不会为队列中元素维护存储空间。与其他队列不同的是,它维护一组线程,这些线程在等待着把元素加入(生产者)或移出(消费者)队列。

7.Java 生产者-消费者模式

5.3.1 双端队列与工作密取

双端队列: 是一种具有队列和栈性质的数据结构,可以(也只能)在线性表的两端进行插入和删除。它比传统的生产者-消费者模式模式具有更高的可伸缩性

工作密取:如果一个消费者完成了自己双端队列中的全部工作,那么它可以从其他消费者的双端队列末尾秘密的获取工作。由于每个线程都有自己的双端队列,所以不会在任务队列上产生竞争。

双端队列适用于工作密取,每个消费者都有自己的双端队列。

5.3.2 Java中Queue的一些常用方法:

方法 作用 说明
add 增加一个元索 如果队列已满,则抛出一个IIIegaISlabEepeplian异常
remove 移除并返回队列头部的元素 如果队列为空,则抛出一个NoSuchElementException异常
element 返回队列头部的元素 如果队列为空,则抛出一个NoSuchElementException异常
offer 添加一个元素并返回true 如果队列已满,则返回false
poll 移除并返问队列头部的元素 如果队列为空,则返回null
peek 返回队列头部的元素 如果队列为空,则返回null

5.3.3 Java中BlockingQueue方法:

方法 作用 说明
put 添加一个元素 如果队列满,则阻塞
offer 添加一个元素并返回true 如果队列已满,则返回false (支持等待指定时间)
poll 移除并返问队列头部的元素 如果队列为空,则可以指定时间,如果指定时间超时还没有数据可取,返回失败
take 移除并返回队列头部的元素 如果队列为空,则阻塞
remainingCapacity 剩余容量 返回int
contains 判断是否包含某个元素 返回boolean
drainTo 移除此队列中所有可用的元素,将它们添加到给定集合中。 返回int

5.3.4 BlockingDeque常用方法

效果 抛异常方法 返回特定值方法 阻塞方法 超时方法
在队列前端插入元素 addFirst(o) offerFirst(o) putFirst(o) offerFirst(o, timeout, timeunit)
在队列前端移除元素 removeFirst(o) pollFirst(o) takeFirst(o) pollFirst(timeout, timeunit)
获取对列第一个元素 getFirst(o) peekFirst(o)
在队列尾端插入元素 addLast(o) offerLast(o) putLast(o) offerLast(o, timeout, timeunit)
在队列尾端移除元素 removeLast(o) pollLast(o) takeLast(o) pollLast(timeout, timeunit)
获取对列最后一个元素 getLast(o) peekLast(o)

5.4 同步类工具

同步工具类:可以是任何一个对象,只要它根据其自身的状态来协调线程的控制流。阻塞队列可以作为同步工具类,其他类型的同步工具类还包括信号量(Semaphore)、 栅栏(Barrier)以及闭锁(Latch)。

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

5.4.1 闭锁

闭锁:是一种同步工具类,可以延迟线程的进度直到其到达终止状态。

1.当闭锁到达结束状态后将不会再改变状态,会一直处于打开状态

2.闭锁的作用:

  • 确保某个计算在其需要的所有资源都被初始化之后才继续执行。二元闭锁(包括两个状态)可以用来表示“资源R已经被初始化”,而所有需要R的操作都必须先在这个闭锁上等待。
  • 确保某个服务在其依赖的所有其他服务都已经启动之后才启动。每个服务都有一个相关的二元闭锁。当启动服务S时,将首先在S依赖的其他服务的闭锁.上等待,在所有依赖的服务都启动后会释放闭锁S,这样其他依赖S的服务才能继续执行。
  • 等待直到某个操作的所有参与者(例如,在多玩家游戏中的所有玩家)都就绪再继续执行。在这种情况中,当所有玩家都准备就绪时,闭锁将到达结束状态。

3.CountDownLatch可以使一个或者多个线程等待一组事件发生。它有一个计数器,起始值是一个整数,表示要等待的事件数量。它有一个countDown方法可以递减计数器,每调用一次就表示一个事件已经发生。还有一个await方法调用时会阻塞到计数器为0,表示所有事件都已经发生

5.4.2 FutureTask

1.FutureTask也可以用作闭锁,(FutureTask 实现了Future语义,表示一种抽象的可生成结果的计算)。

2.FutureTask表示的计算是通过Callable来实现的,相当于一种可生成结果的Runnable,并且可以处于以下3种状态:等待运行(Waiting to run),正在运行(Running)和运行完成(Completed)。 “执行完成”表示计算的所有可能结束方式,包括正常结束、由于取消而结束和由于异常而结束等。当FutureTask进入完成状态后,它会永远停止在这个状态上。

3.Future.get的行为取决于任务的状态。如果任务已经完成,那么get会立即返回结果,否则get将阻塞直到任务进人完成状态,然后返回结果或者抛出异常。

4.FutureTask类的使用

5.批量任务运行并返回结果

5.4.3 信号量(Semaphore)

1.计数信号量可以用来实现资源池,或者对容器施加边界(默认是非公平的)

2.Semaphore中管理着一组虚拟的许可(permit)

  • 许可的初始数量可通过构造函数来指定。
  • 在执行操作时可以首先通过调用acquire方法获得许可(只要还有剩余的许可),并在使用以后调用release方法释放许可。
  • 如果没有许可,那么acquire方法将阻塞直到有许可(或者直到被中断或者操作超时)。
  • release方法将返回一个许可给信号量。

3.计算信号量的一种简化形式是二值信号量,即初始值为1的Semaphore。二值信号量可以用做互斥体(mutex),并具备不可重入的加锁语义:谁拥有这个唯一的许可, 谁就拥有了互斥锁。

4.Semaphore 计数信号量方法及简单实例

5.4.4 栅栏(Barrier)

1.所有线程必须同时到达栅栏位置,才能继续执行,可以反复使用。闭锁是为了等待事件,是一次性对象。

2.当所有线程都到达了栅栏位置后,就会打开栅栏,所有线程被释放。栅栏被重置,以便下次使用。

3.CyclicBarrier是栅栏的一种实现,构造方法中parties代表要到达的线程总数,barrierAction是一个Runnable,在通过栅栏时调用(在另一个线程中)。

4.当线程到达栅栏处时,让线程调用CyclicBarrier中await方法,该方法会阻塞到所有线程到达栅栏处,如果调用超时,或者await阻塞的线程被中断,那么栅栏就被认为是打破了,所有阻塞的await调用都将终止并抛出BrokenBrrierException。如果成功地通过栅栏,在通过栅栏后该方法会为每个线程返回一个唯一的到达索引,我们可以利用这个唯一的索引来选举出一个领导线程,并在下一次迭代中由其执行一些特殊操作

5.Exchanger是一个两方栅栏,可用于两个线程之间交换信息。可简单地将Exchanger对象理解为一个包含两个格子的容器,通过exchanger方法可以向两个格子中填充信息。当两个格子中的均被填充时,该对象会自动将两个格子的信息交换,然后返回给线程,从而实现两个线程的信息交换(线程安全的交换)。

第一部分基础知识小结

1.可变状态是至关重要的。所有的并发问题都可以归结为如何协调对并发状态的访问。可变状态越少,就越容易确保线程安全性。

2.尽量将域声明为final 类型,除非需要它们是可变的。

3.不可变对象一定是线程安全的。不可变对象能极大地降低并发编程的复杂性。它们更为简单而且安全,可以任意共享而无须使用加锁或保护性复制等机制。

4.封装有助于管理复杂性。在编写线程安全的程序时,虽然可以将所有数据都保存在全局变量中,但为什么要这样做?将数据封装在对象中,更易于维持不变性条件。将同步机制封装在对象中,更易于遵循同步策略。

5.用锁来保护每个可变变量。

6.当保护同一个不变性条件中的所有变量时,要使用同一个锁。

7.在执行复合操作期间,要持有锁。

8.如果从多个线程中访问同一个可变变量时没有同步机制,那么程序会出现问题。

9.不要故作聪明地推断出不需要使用同步。

10.在设计过程中考虑线程安全,或者在文档中明确地指出它不是线程安全的。

11.将同步策略文档化。

第二部分结构化并发应用程序

六.任务执行

任务:通常是一些抽象的且离散的工作单元。

通过把应用程序的工作分解到多个任务中,可以简化程序的组织结构,提供一种自然的事务边界来优化错误恢复过程,以及提供一种自然的并行工作结构来提升并发性。

6.1 在线程中执行任务

1.大多数服务器应用程序都提供了一种自然的任务边界选择方式:以独立的客户请求为边界

2.串行缺点:只能一个一个任务的处理,当一个任务需要很长时间或者阻塞时,其它的请求不能及时的处理。

3.并行优点:

  • 任务处理过程从主线程中分离出来,使得主循环能够更快地重新等待下一个到来的连接。这使得程序在完成前面的请求之前可以接受新的请求,从而提高响应性。

  • 任务可以并行处理,从而能同时服务多个请求。如果有多个处理器,或者任务由于某种原因被阻塞,例如等待I/O完成、获取锁或者资源可用性等,程序的吞吐量将得到提高。

  • 任务处理代码必须是线程安全的,因为当有多个任务时会并发地调用这段代码。在正常负载情况下,为每个任务分配一个线程的方法能提升串行执行的性能。

4.无限创建线程的不足

  • 线程生命周期的开销非常高。线程的创建与销毁并不是没有代价的。如果请求的到达率非常高且请求的处理过程是轻量级的,那么为每个请求创建一个新线程将消耗大量的计算资源。

  • 资源消耗。活跃的线程会消耗系统资源,尤其是内存。如果可运行的线程数量多于可用处理器的数量,那么有些线程将闲置。大量空闲的线程会占用许多内存,给垃圾回收器带来压力,而且大量线程在竞争CPU资源时还将产生其他的性能开销。如果你已经拥有足够多的线程使所有CPU保持忙碌状态,那么再创建更多的线程反而会降低性能。

  • 稳定性。在可创建线程的数量上存在一个限制。这个限制值将随着平台的不同而不同,并且受多个因素制约,包括JVM的启动参数、Thread构造函数中请求的栈大小,以及底层操作系统对线程的限制等。如果破坏了这些限制,那么很可能抛出OutOfMemoryError异常,要想从异常恢复过来是非常危险的,应该通过构造函数来避免超出这些限制

6.2 Executor框架

线程池:是指管理一组同构工作线程的资源池。

1.在线程池中执行任务比为每个任务分配一个线程优势更多。通过重用现有的线程而不是创建新线程,可以在处理多个请求时分摊在线程创建和销毁中产生的开销。

2.执行策略:通过将任务的提交与执行解耦开来,从而无须太大的困难就可以为某种类型的任务指定和修改执行策略。在执行策略中定义了任务执行的“What、 Where、When、How”等方面,包括:

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

6.2.1 Executor常用的创建线程池方法

1.newFixedThreadPool。newFixedThreadPool 将创建一个固定长度的线程池,每当提交一个任务时就创建一个线程,直到达到线程池的最大数量,这时线程池的规模将不再变化(如果某个线程由于发生了未预期的Exception而结束,那么线程池会补充一个新的线程)。

2.newCachedThreadPool。newCachedThreadPool将创建一个可缓存的线程池,如果线程池的当前规模超过了处理需求时,那么将回收空闲的线程,而当需求增加时,则可以添加新的线程,线程池的规模不存在任何限制。

3.newSingleThreadExecutor。newSingleThreadExecutor 是一个单线程的Executor,它创建单个工作者线程来执行任务,如果这个线程异常结束,会创建另一个线程来替代。newSingleThreadExecutor能确保依照任务在队列中的顺序来串行执行(例如FIFO、LIFO、优先级)。

4.newScheduledThreadPool。newScheduledThreadPool创建了一个固定长度的线程池,而且以延迟或定时的方式来执行任务,类似于Timer。

6.2.2 Executor的生命周期

生命周期有3种状态:运行、关闭、终止

1.由于Executor以异步的方式来执行任务,因此在任何时刻之前提交的任务状态不是立即可见的。有些任务可能已经完成,有些可能正在运行,而其他的任务可能在队列中等待执行。

2.Executor提供了一些用于管理生命周期的方法

  • shutdown。它会平缓的关闭Executor:不在接受新的任务并且会等到已经提交的任务执行结束(包括在队列中等待的任务)

  • shutdownNow。它会立即关闭Executor:它会尝试关闭正在执行的任务并且不会执行正在等待的任务。

  • awaitTermination。 阻塞当前线程,直到Executor生命周期达到终止状态,或者超时及线程被中断(抛出异常)

6.2.3 延迟任务与周期任务

1.Timer类负责管理延迟任务以及周期任务

2.Timer缺点

  • Timer 在执行所有的定时任务时只会创建一个线程。如果每某个线程执行的时间过长会影响其他线程的精确性。例如:A任务执行时间为1000ms,而B任务是每100ms执行一次,那么B任务就是就会在A任务执行完成后连续调用10次或者直接丢失这期间的10次调用

  • 如果Timer在执行的过程中抛出一个异常(捕获的异常不算)那么该线程被终止,已经被调度但尚未执行的任务不会执行,新提交的任务也不会被调度

3.如果要构建自己的调度服务,那么可以使用DelayQueue,它实现了BlockingQueue,并为ScheduledThreadPoolExecutor提供调度功能。DelayQueue 管理着一组Delayed对象。每个Delayed对象都有一个相应的延迟时间:在DelayQueue中,只有某个元素逾期后,才能从DelayQueue中执行take操作。从DelayQueue中返回的对象将根据它们的延迟时间进行排序。

小结

1.通过围绕任务执行来设计应用程序,可以简化开发过程,并有助于实现并发。

2.Executor框架将任务提交与执行策略解耦开来,同时还支持多种不同类型的执行策略。当需要创建线程来执行任务时,可以考虑使用Executor。

3.要想在将应用程序分解为不同的任务时获得最大的好处,必须定义清晰的任务边界。某些应用程序中存在着比较明显的任务边界,而在其他一些程序中则需要进一步分析才能揭示出粒度更细的并行性。

七.取消与关闭

1.任务和线程的启动很容易。一般情况下我们不会终止任务,但是在少数情况下我们希望提前结束任务或者线程。(有可能是因为用户取消了操作或者应用程序需要被快速关闭)

2.Java并没有提供任何机制来安全的终止线程。但是提供了中断,这是一种协作机制,可以使一个线程终止另一个线程当前的工作。

7.1 任务取消

7.1.1 中断、响应中断

1.方法:

  • Thread.currentThread().interrupt(); //中断线程

  • Thread.currentThread().isInterrupted(); //返回线程的中断状态

  • Thread.currentThread().interrupted(); //恢复被中断的线程

2.抛出InterruptedException异常后中断的线程也会恢复。

3.在抛出InterruptedException异常之前,JVM会先把线程的中断标识位清除,然后才会抛出异常,这时如果调用isInterrupted(),将会返回false。

4.如果当前线程在非阻塞的情况下被中断,它的中断状态将会被设置成true,然后根据将被取消的操作来检查中断状态以判断是否发生了中断。通过这种方法中断操作将变得”有粘性“,如果不触发interruptedException,那么中断状态将一直保持,直到明确的清除中断状态

5.中断操作并不会真正的中断一个正在运行的线程,它只是发出一个请求由线程自己在一个合适的时刻中断自己

6.合适的时机:

  • 如果该线程处在可中断状态下,(调用了xx.wait(),或者Thread.sleep()等特定会发生阻塞的方法),那么该线程会立即被唤醒,同时会收到一个InterruptedException,如果是阻塞在io上,对应的资源会被关闭。 但是由于在抛出InterruptedException时会将中断状态设置为false,所以程序会继续执行

  • 如果该线程处在不可中断状态下,(没有调用阻塞方法)那么Java只是设置一下该线程的interrupt状态为true,其他事情都不会发生,如果该线程之后会调用阻塞方法,那到时候线程会马会上跳出,并抛出InterruptedException,接下来的事情就跟第一种状况一致了。如果不会调用阻塞方法,那么这个线程就会一直执行下去。除非你就是要实现这样的线程,一般高性能的代码中肯定会有wait(),yield()之类让出cpu的函数,不会发生后者的情况。

7.1.2 中断策略

中断策略:规定线程如何解释某个中断请求。当发现中断请求时,应该做哪些工作(如果需要的话),哪些工作单元对于中断来说是原子操作,以及以多快的速度来响应中断。

1.最合理的中断策略是某种形式的线程级(Thread-Level)取消操作或服务级(Service-Level)取消操作。尽快退出,在必要时进行清理,通知某个所有者该线程已经退出。此外还可以建立其他的中断策略,例如暂停服务或重新开始服务,但对于那些包含非标准中断策略的线程或线程池,只能用于可以知道这些策略的任务中。

2.区分任务和线程对中断的反应是很重要的。一个中断请求可以有一个或多个接收者;中断线程池中的某个工作者线程,同时意味着“取消当前任务”和“关闭工作者线程”。

3.当检查到中断请求时,任务并不需要放弃所有的操作,它可以推迟处理中断请求,并直到某个更合适的时刻。因此需要记住中断请求,并在完成当前任务后抛出InterruptedException或者表示已收到中断请求。这项技术能够确保在更新过程中发生中断时,数据结构不会被破坏。

4.任务不应该对执行该任务的线程的中断策略做出任何假设,除非该任务被专门设计为在服务中运行,并且在这些服务中包含特定的中断策略。无论任务把中断视为取消,还是其他某个中断响应操作,都应该小心地保存执行线程的中断状态。

7.1.3 响应中断

1.当调用可中断的阻塞函数时,例如Thread.sleep或BlockingQueue.put等,有两种实用策略可用于处理InterruptedException:

  • 传递异常(可能在执行某个特定于任务的清除操作之后抛出,或者直接抛出),从而使你的方法也成为可中断的阻塞方法。

  • 恢复中断状态,从而使调用栈中的上层代码能够对其进行处理。

2.对于一些不支持取消但仍可以调用可中断阻塞方法的操作,它们必须在循环中调用这些方法,并在发现中断后重新尝试。在这种情况下,它们应该在本地保存中断状态,并在返回前恢复状态而不是在捕获InterruntedExcention时恢复状态。

3.thread.join() 在A线程中调用thread的join()方法,A线程会被挂起,直到thread线程的方法执行完。join的有参方法是加了对象锁的;join有三个重载版本join(long millis, int nanos)、join(long millis)、join()。其中join(long millis)是底层方法,其他两个都是调用了该方法

7.1.4 通过Future的cancel方法实现取消

  1. 该方法返回一个boolean,表示取消操作是否成功(只能表示任务是否接受了中断请求,并不能保证任务接受中断请求后检测并处理了中断);该方法的底层其实是调用了interrupt()方法

2.使用cancel后futureTask无法get出子线程的值,会报CancellationException

3.cancel方法有一个boolean类型的mayInterruptIfRunning参数 根据任务状态的不同有不同的效果:

  • 等待状态。此时调用cancel()方法不管传入true还是false都会标记为取消,任务依然保存在任务队列中,但当轮到此任务运行时会直接跳过。

  • 完成状态。此时cancel()不会起任何作用,因为任务已经完成了。

  • 运行中。此时传入true会中断正在执行的任务,传入false则不会中断。

4.Future.cancel(true)适用于:

  • 长时间处于运行的任务,并且能够处理interrupt

5.Future.cancel(false)适用于:

  • 未能处理interrupt的任务

  • 不清楚任务是否支持取消

  • 需要等待已经开始的任务执行完成

7.1.5 处理不可中断阻塞

1.Java中很多可阻塞方法都是可以通过提前返回或者抛出InterrupteException来响应中断请求的。但是不是所有的可阻塞方法都能响应中断;如果一个线程由于执行同步的Socket I/O或者等待获得内置锁而阻塞,那么中断请求除了设置线程的中断状态什么作用也没有。

2.不可中断的阻塞操作:

  • Java.io包中的同步Socket I/O。在服务器应用程序中,最常见的阻塞I/O形式就是对套接字进行读取和写人。虽然InputStream和OutputStream中的read和write等方法都不会响应中断,但通过关闭底层的套接字,可以使得由于执行read或write等方法而被阻塞的线程抛出一个SocketException。

  • Java.io包中的同步I/O。当中断一个正在InterruptibleChannel.上等待的线程时,将抛出ClosedByInterruptException并关闭链路(这还会使得其他在这条链路上阻塞的线程同样抛出ClosedByInterruptException)。当关闭一个InterruptibleChannel时,将导致所有在链路操作.上阻塞的线程都抛出AsynchronousCloseException。大多数标准的Channel都实现了InterruptibleChannel.

  • Selector的异步I/O。如果一个线程在调用Selector.select方法(在java.nio.channels中)时阻塞了,那么调用close或wakeup方法会使线程抛出ClosedSelectorException并提前返回。

  • 获取某个锁。如果一个线程由于等待某个内置锁而阻塞,那么将无法响应中断,因为线程认为它肯定会获得锁,所以将不会理会中断请求。但是,在Lock类中提供了lockInterruptibly方法,该方法允许在等待一个锁的同时仍能响应中断。

7.2 停止基于线程的服务

线程的所有者: 创建该线程的类。

1.线程的所有权是不能传递的

2.服务中应该提供线程的生命周期方法来关闭它自己及它拥有的线程

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

7.2.1 关闭生产者-消费者服务下的线程

毒丸对象:是指放在队列上的对象,当消费者得到这个对象时,立即停止。

为了保证生产者线程结束后,消费者线程也能在完成所有工作后退出。一般用shutdown或’‘毒丸’'对象来关闭,具体情况如下:

  • 在使用ExecutorService来创建消费者线程时(推荐该方法创建消费者线程,这样我们可以更好的管理消费者的生命周期),一般使用shutdown来关闭线程池,也可以用shutdownNow来关闭但是shutdownNow有局限性(当通过shutdownNow来强行关闭ExecutorService时,它会尝试取消正在执行的任务,并返回所有已提交但尚未开始的任务。但是它不能返回那些被取消的执行的任务)

  • 使用”毒丸“对象。在先进先出(FIFO)队列中,"毒丸"将保证消费者在关闭之前已经完成的队列中的全部工作,并且在生产者提交了”毒丸“后不再提交任何工作。但是只有在生产者和消费者都已知的情况下,才可以使用”毒丸“对象;在有多个生产者时,只需要每个生产者都向队列中放入一个”毒丸“对象,并且消费者仅当接收到全部”毒丸“对象才停止;多个消费者也可以,然而,当生产者和消费者数量较大时,这种方法将变得难以使用;而且只有在无界队列中,”毒丸“对象才能可靠地工作。

7.3 处理非正常的线程终止

一个线程在抛出未捕获异常时会直接终止,从而可能影响其它任务的执行。解决办法如下:

1.在任务中使用try-catch-finally来获取异常及执行如果抛出异常该做什么(一般在catch里面记录异常,finally里面将异常传递给日志系统),不要在外部捕获异常,不然可能导致一些问题的出现,比如异常的时候无法回收一些系统资源,或者没有关闭当前的连接等等

2.当一个线程由于未捕获异常而退出时,JVM会把这个事件报告给应用程序提供的UncaughtExceptionHandler异常处理器,该处理器有一个uncaughtException(Thread t, Throwable e)方法,可以在该方法内部尝试重启线程、关闭应用程序、或者执行其他方法。如果没有提供任何异常处理器,那么默认将栈追踪信息输出到System.err。但是只有通过execute方法提交的任务才能将它抛出的未捕获异常交给UncaughtExceptionHandler异常处理器,如果是submit提交的任务抛出了未捕获异常,那么该线程会结束,异常将会被Futrue.get封装在ExecutionException中重新抛出

7.4 JVM关闭

线程可分为两种:普通线程和守护线程。在JVM启动时创建的所有线程中,除了主线程以外,其他的线程都是守护线程(例如垃圾回收器以及其他执行辅助工作的线程)。

1.当创建一个新线程时,新线程将继承创建它的线程的守护状态,因此在默认情况下,主线程创建的所有线程都是普通线程。

2.普通线程与守护线程之间的差异仅在于当线程退出时发生的操作。当一个线程退出时,JVM会检查其他正在运行的线程,如果这些线程都是守护线程,那么JVM会正常退出操作。当JVM停止时,所有仍然存在的守护线程都将被抛弃一既不会执行finally代码块,也不会执行回卷栈,而JVM只是直接退出。

3.当我们需要创建一个线程来执行一些辅助工作,但又不希望这个线程阻碍JVM的关闭。在这种情况下我们就需要守护线程

4.JVM关闭的方法:

  • 当最后一个非守护线程结束

  • 调用了System.exit

  • 通过其他特定于平台的方法关闭。例如发送了SIGINT信号或者键入Ctrl+C

  • 通过调用Runtime.halt或者在操作系统中杀死JVM进程等强制关闭JVM

7.4.1 关闭钩子

关闭钩子:是指通过Runtime.addShutdownHook注册的但尚未开始的线程。

1.在正常关闭中,JVM首先调用所有已注册的关闭钩子。JVM并不能保证关闭钩子的调用顺序。

2.在关闭应用程序线程时,如果有(守护或非守护)线程仍然在运行,那么这些线程接下来将与关闭进程并发执行。

3.当所有的关闭钩子都执行结束时,如果runFinalizersOnExit为true,那么JVM将运行终结器,然后再停止。

4.JVM并不会停止或中断任何在关闭时仍然运行的应用程序线程。

5.当JVM最终结束时,这些线程将被强行结束。如果关闭钩子或终结器没有执行完成,那正常关闭进程“挂起”并且JVM必须被强行关闭。当被强行关闭时,只是关闭JVM,而不会运行关闭钩子。

6.关闭钩子应该是线程安全的:它们在访问共享数据时必须使用同步机制,并且小心地避免发生死锁,这与其他并发代码的要求相同。

7.关闭钩子不应该对应用程序的状态(例如,其他服务是否已经关闭,或者所有的正常线程是否已经执行完成)或者JVM的关闭原因做出任何假设,因此在编写关闭钩子的代码时必须考虑周全。最后,关闭钩子必须尽快退出,因为它们会延迟JVM的结束时间,而用户可能希望JVM能尽快终止。

8.关闭钩子不应该依赖那些可能被应用程序或其他关闭钩子关闭的服务。实现这种功能的一种方式是对所有服务使用同一个关闭钩子( 而不是每个服务使用一个不同的关闭钩子),并且在该关闭钩子中执行一系列的关闭操作。这确保了关闭操作在单个线程中串行执行,从而避免了在关闭操作之间出现竞态条件或死锁等问题。无论是否使用关闭钩子,都可以使用这项技术,通过将各个关闭操作串行执行而不是并行执行,可以消除许多潜在的故障。当应用程序需要维护多个服务之间的显式依赖信息时,这项技术可以确保关闭操作按照正确的顺序执行。

9.关闭钩子可以用于实现服务或应用程序的清理工作,例如删除临时文件,或者清除无法由操作系统自动清除的资源。

小结

在任务、线程、服务以及应用程序等模块中的生命周期结束问题,可能会增加它们在设计和实现时的复杂性。Java并没有提供某种抢占式的机制来取消操作或者终结线程。相反,它提供了一种协作式的中断机制来实现取消操作,但这要依赖于如何构建取消操作的协议,以及能否始终遵循这些协议。通过使用FutureTask和Executor框架,可以帮助我们构建可取消的任务和服务。

八.线程池的使用

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

线程饥饿死锁: 所有正在执行的任务线程由于等待其它仍处于工作队列中的任务而阻塞的现象被称为线程饥饿死锁(比如在一个任务中将另一个任务提交到同一个Executor)

1.虽然Executor框架为制定和修改执行策略都提供了相当大的灵活性,但并非所有的任务都能适用所有的执行策略。有些类型的任务需要明确地指定执行策略,包括:

  • 依赖性任务。大多数行为正确的任务都是独立的:它们不依赖于其他任务的执行时序、执行结果或其他效果。当在线程池中执行独立的任务时,可以随意地改变线程池的大小和配置,这些修改只会对执行性能产生影响。然而,如果提交给线程池的任务需要依赖其他的任务,那么就隐含地给执行策略带来了约束,此时必须小心地维持这些执行策略以避免产生活跃性问题。

  • 使用线程封闭机制的任务。与线程池相比,单线程的Executor能够对并发性做出更强的承诺。它们能确保任务不会并发地执行,使你能够放宽代码对线程安全的要求。对象可以封闭在任务线程中,使得在该线程中执行的任务在访问该对象时不需要同步,即使这些资源不是线程安全的也没有问题。这种情形将在任务与执行策略之间形成隐式的耦合。任务要求其执行所在的Executor是单线程的。如果将Executor从单线程环境改为线程池环境,那么将会失去线程安全性。

  • 对响应时间敏感的任务。GUI应用程序对于响应时间是敏感的:如果用户在点击按钮后需要很长延迟才能得到可见的反馈,那么他们会感到不满。如果将一个运行时间较长的任务提交到单线程的Executor中,或者将多个运行时间较长的任务提交到一个只包含少量线程的线程池中,那么将降低由该Executor管理的服务的响应性。

  • 使用ThreadLocal的任务。ThreadLocal 使每个线程都可以拥有某个变量的一个私有“版本”。然而,只要条件允许,Executor 可以自由地重用这些线程。在标准的Executor实现中,当执行需求较低时将回收空闲线程,而当需求增加时将添加新的线程,并且如果从任务中拋出了一个未检查异常,那么将用一个新的工作者线程来替代抛出异常的线程。只有当线程本地值的生命周期受限于任务的生命周期时,在线程池的线程中使用ThreadLocal才有意义,而在线程池的线程中不应该使用ThreadLocal在任务之间传递值。

2.在一些任务中,需要拥有或排除某种特定的执行策略。如果某些任务依赖于其他的任务,那么会要求线程池足够大,从而确保它们依赖任务不会被放入等待队列中或被拒绝,而采用线程封闭机制的任务需要串行执行。

8.1.1 设置线程池的大小

线程池的理想大小取决于被提交任务的类型及所部署系统的特性。在代码中通常不会固定线程的大小,而应该通过某种配置机制来提供,或者根据Runtime.getRuntime().availableProcessors()来动态计算。计算公式: cpu数*cpu利用率*(1+线程等待时间/线程执行时间)

8.2 配置ThreadPoolExecutor

1.ThreadPoolExecutor为一些Executor提供了基本的实现,这些Executor是由Executors中的newCachedThreadPool、newFixedThreadPool 和newScheduledThreadExecutor等工厂方法返回的。ThreadPoolExecutor 是一个灵活的、稳定的线程池,允许进行各种定制。

2.如果默认的执行策略不能满足需求,那么可以通过ThreadPoolExecutor的构造函数来实例化一个对象,并根据自己的需求来定制,并且可以参考Executors的源代码来了解默认配置下的执行策略,然后再以这些执行策略为基础进行修改。

8.2.1 线程的创建与销毁

1.线程池的基本大小(Core Pool Size)、 最大大小(Maximum Pool Size)以及存活时间等因素共同负责线程的创建与销毁。

2.基本大小也就是线程池的目标大小,即在没有任务执行时线程池的大小,并且只有在工作队列满了的情况下才会创建超出这个数量的线程目。

3.线程池的最大大小表示可同时活动的线程数量的上限。如果某个线程的空闲时间超过了存活时间,那么将被标记为可回收的,并且当线程池的当前大小超过了基本大小时,这个线程将被终止。

4.通过调节线程池的基本大小和存活时间,可以帮助线程池回收空闲线程占有的资源,从而使得这些资源可以用于执行其他工作。(显然,这是一种折衷:回收空闲线程会产生额外的延迟,因为当需求增加时,必须创建新的线程来满足需求。)

5.newFixedThreadPool工厂方法将线程池的基本大小和最大大小设置为参数中指定的值,而且创建的线程池不会超时。newCachedThreadPool 工厂方法将线程池的最大大小设置为Integer.MAX_VALUE,而将基本大小设置为零,并将超时设置为1分钟,这种方法创建出来的线程池可以被无限扩展,并且当需求降低时会自动收缩。其他形式的线程池可以通过显式的ThreadPoolExecutor构造函数来构造。

8.2.2 管理队列任务

1.如果无限制地创建线程,那么将导致不稳定性,可以通过采用固定大小的线程池来解决这个问题。然而当负载很高的情况下,应用程序仍可能耗尽资源。比如当新请求到达的速度超过线程池处理请求的速度,那么Executor会将这些来不及处理的请求放在其管理的Runnable队列中等待,而不会像线程那样去竞争CPU资源。通过一个Runnable和一个链表节点来表现一个等待中的任务会比使用线程来表示一个等待中的任务开销低很多,但是如果请求的速率超过了服务器的处理速率,那么仍然有可能会耗尽资源

2.newFixedThreadPool和newSingleThreadExecutor在默认情况小使用的是一个无界的LinkedBlockingQueue。如果所有工作线程都处于忙碌状态,那么任务将在队列中等候。如果任务持续快速的到达,并且超过了线程池处理它们的速度,那么队列将无限的增加

3.在newCachedThreadPool中使用的是SynchhronousQueue队列,它并不是一个真正的队列,而是一种在线程之间进行任务移交的机制,使用直接它可以避免任务排队。要将一个元素放入其中必须有一个线程正在等待接受这个任务。如果没有线程正在等待,并且线程池的当前大小小于最大值,那么ThreadPoolExecutor将创建一个新的线程,否则根据饱和策略,这个任务将被拒绝。

4.只有当任务相互独立时,为线程池或工作队列设置界限才是合理的。如果任务之间存在依赖性,那么有界的线程池或队列就可能导致线程“饥饿”死锁问题。此时应该使用无界的线程池,例如newCachedThreadPool。

8.2.3 饱和策略

1.当有界队列被填满后,饱和策略开始发挥作用ThreadPoolExecutor的饱和策略可以通过调用setRejectedExecutionHandler来修改。( 如果某个任务被提交到一个已被关闭的Executor时,也会用到饱和策略。)

2.JDK提供了几种不同的RejectedExecutionHandler实现,每种实现都包含有不同的饱和策略:AbortPolicy(默认饱和策略,中止策略)、CallerRunsPolicy(调用者运行策略)、DiscardPolicy(抛弃策略)、DiscardOldestPolicy(抛弃最旧的策略)

  • “中止(Abort)”策略是默认的饱和策略,该策略将拋出未检查的RejectedExecution-Exception。调用者可以捕获这个异常,然后根据需求编写自己的处理代码。

  • 当新提交的任务无法保存到队列中等待执行时,“抛弃( Discard)”策略会悄悄抛弃该任务。

  • “抛弃最旧的(Discard-Oldest)”策略则会抛弃下一个将被执行的任务,然后尝试重新提交新的任务。(如果工作队列是一个优先队列,那么“抛弃最旧的”策略将导致抛弃优先级最高的任务,因此最好不要将“抛弃最旧的”饱和策略和优先级队列放在一起使用。)

  • “调用者运行( Caller-Runs)”策略实现了-种调节机制,该策略既不会抛弃任务,也不会抛出异常,而是将某些任务回退到调用者,从而降低新任务的流量。它不会在线程池的某个线程中执行新提交的任务,而是在一个调用了execute的线程中执行该任务。

3.当工作队列被填满后,没有预定义的饱和策略来阻塞execute。然而,通过使用Semaphore(信号量)来限制任务的到达率,就可以实现饱和策略这个功能。如果线程池使用一个无界队列(因为不能限制队列的大小和任务的到达率),将信号量的上界设置为线程池的大小。这样当没有空闲线程时任务就会在线程池队列中等待

8.2.4 线程工厂

如果在应用程序中需要利用安全策略来控制对某些特殊代码库的访问权限,那么可以通过Executor中的privilegedThreadFactory工厂来定制自己的线程工厂。通过这种方式创建出来的线程,将与创建privilegedThreadFactory的线程拥有相同的访问权限、AccessControlContext 和contextClassLoader。如果不使用privilegedThreadFactory,线程池创建的线程将从在需要新线程时调用execute或submit的客户程序中继承访问权限,从而导致令人困惑的安全性异常

8.3 扩展ThreadPoolExecutor

1.ThreadPoolExecutor是可扩展的,它提供了几个可以在子类化中改写的方法: beforeExecute、afterExecute和terminated,这些方法可以用于扩展ThreadPoolExecutor的行为。

2.在执行任务的线程中将调用beforeExecute和afterExecute等方法,在这些方法中还可以添加日志、计时、监视或统计信息收集的功能。无论任务是从run中正常返回,还是抛出一个异常而返回,afterExecute 都会被调用。(如果任务在完成后带有一个Error,那么就不会调用afterExecute.)如果beforeExecute抛出一个RuntimeException,那么任务将不被执行,并且afterExecute也不会被调用。

3.在线程池完成关闭操作时调用terminated,也就是在所有任务都已经完成并且所有工作者线程也已经关闭后。terminated可以用来释放Executor在其生命周期里分配的各种资源,此外还可以执行发送通知、记录日志或者收集finalize统计信息等操作。

小结

对于并发执行的任务,Executor框架是一种强大且灵活的框架。它提供了大量可调节的选项,例如创建线程和关闭线程的策略,处理队列任务的策略,处理过多任务的策略,并且提供了几个钩子方法来扩展它的行为。然而,与大多数功能强大的框架一样,其中有些设置参数并不能很好地工作,某些类型的任务需要特定的执行策略,而一些参数组合则可能产生奇怪的结果。

第三部分活跃性、性能与并发开发注意事项

十.避免活跃性危险

在安全性与活跃性之间通常存在着某种制衡。我们使用加锁机制来确保线程安全,但如果过度地使用加锁,则可能导致锁顺序死锁。同样,我们使用线程池和信号量来限制对资源的使用,但这些被限制的行为可能会导致资源死锁。Java应用程序无法从死锁中恢复过来,因此在设计时一定要排除那些可能导致死锁出现的条件。

10.1 死锁

死锁:线程A持有一个锁L并想获得锁M的同时,线程B持有M锁并尝试获得L,那么这两个线程将永远地等待下去。这种情况是最常见的死锁形式(抱死)

在数据库系统的设计中考虑了监测死锁以及从死锁中恢复。

  • 在执行一个事务时可能会获取多个锁,并一直持有锁直到事务提交。所以在两个事务之间很可能会发生死锁,但是这种情况并不多见,而且数据库服务器不会让这种情况发生,当它监测到一组事务发生死锁时,它将会选择一个牺牲者并放弃这个事务。作为牺牲者的事务会释放它所持有的资源,从而使其他事务继续进行。

10.1.1 锁顺序死锁

两个线程试图以不同的顺序来获得相同的锁,就会产生死锁。如果按照相同的顺序来请求锁,那么就不会出现循环的加锁依赖性,也就不会产生死锁

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

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

10.1.3 开放调用

开放调用:如果A方法在调用B方法时不需要持有一个锁,那么这种调用被称为开放调用。开放调用的类通常能表现出更好的行为,并且与那些在调用方法时需要持有锁的类相比,也更易于编写。

1.通过尽可能地使用开放调用,将更易于找出那些需要获得多个锁的代码路径,因此也就更容易确保采用一致的顺序来获得锁

2.在程序中应尽量使用开放调用。与那些在持有锁时调用外部方法的程序相比,依赖于开放调用的程序更易于进行死锁分析

10.1.4 资源死锁

1.正如当多个线程相互持有彼此等待的锁且不释放自己的锁时会发生死锁一样,当它们在相同资源集合上等待时,也会发生死锁

2.线程饥饿死锁就是一种资源死锁的一种表现形式

3.相互依赖的任务不能共用一个有界线程池(资源池)

10.2 死锁的避免与诊断

1.如果一个程序每次至多只能获得一个锁,那么就不会产生顺序死锁的问题,但是这种情况通常并不现实,但如果能够实现每次只获得一个锁,那么就能省去很多的工作。

2.如果必须获得多个锁,那么在设计时必须考虑锁的顺序:尽量的减少潜在的加锁交互数量,将获得锁时需要遵守的协议写入正式的文档并始终遵循这些协议

3.尽可能使用开放调用

10.2.1 支持定时的锁

1.显式使用Lock类中的定时tryLock功能来代替内置锁机制。当使用内置锁时,只要没有获得锁,就会永远等待下去,而显式锁则可以指定一个超时时限(Timeout),在等待超过该时间后tryLock会返回一个失败信息。如果超时时限比获取锁的时间要长很多,那么就可以在发生某个意外情况后重新获
得控制权。

2.即使在整个系统中没有始终使用定时锁,使用定时锁来获取多个锁也能有效地应对死锁问题。如果在获取锁时超时,那么可以释放这个锁,然后后退并在一段时间后再次尝试,从而消除了死锁发生的条件,使程序恢复过来。(这项技术只有在同时获取两个锁时才有效,如果在嵌套的方法调用中请求多个锁,那么即使你知道已经持有了外层的锁,也无法释放它。)

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

1.虽然防止死锁的主要责任在于你自己,但JVM仍然通过线程转储(ThreadDump)来帮助识别死锁的发生。线程转储包括各个运行中的线程的栈追踪信息,这类似于发生异常时的栈追踪信息。线程转储还包含加锁信息,例如每个线程持有了哪些锁,在哪些栈帧中获得这些锁,以及被阻塞的线程正在等待获取哪一个锁。在生成线程转储之前,JVM将在等待关系图中通过搜索循环来找出死锁。如果发现了一个死锁,则获取相应的死锁信息,例如在死锁中涉及哪些锁和线程,以及这个锁的获取操作位于程序的哪些位置。

2.如果使用显式的Lock类而不是内部锁,那么Java5.0并不支持与Lock相关的转储信息,在线程转储中不会出现显式的Lock。虽然Java6中包含对显式Lock的线程转储和死锁检测等的支持,但在这些锁上获得的信息比在内置锁上获得的信息精确度低。内置锁与获得它们所在,的线程栈帧是相关联的,而显式的Lock只与获得它的线程相关联。

10.3 其他活跃性危险

尽管死锁是最常见的活跃性危险,但在并发程序中还存在一些其他活跃性危险,包括:饥饿、丢失信号和活锁等

10.3.1 饥饿

饥饿:当线程由于无法访问它所需要的资源而不能继续执行时,就发生了“饥饿”。

1.引发饥饿的最常见资源就是CPU时钟周期。如果在Java应用程序中对线程的优先级使用不当,或者在持有锁时执行一些无法结束的结构(例如无限循环,或者无限制地等待某个资源),那么也可能导致饥饿,因为其他需要这个锁的线程将无法得到它。

2.在Thread API中定义的线程优先级只是作为线程调度的参考。在Thread API中定义了10个优先级,JVM根据需要将它们映射到操作系统的调度优先级。这种映射是与特定平台相关的,因此在某个操作系统中两个不同的Java优先级可能被映射到同一个优先级,而在另一个操作系统中则可能被映射到另一个不同的优先级。在某些操作系统中,如果优先级的数量少于10个,那么有多个Java优先级会被映射到同一个优先级。

3.操作系统的线程调度器会尽力提供公平的、活跃性良好的调度,甚至超出Java语言规范的需求范围。在大多数Java应用程序中,所有线程都具有相同的优先级Thread.NORM_PRIORITY。线程优先级并不是一种直观的机制,而通过修改线程优先级所带来的效果通常也不明显。当提高某个线程的优先级时,可能不会起到任何作用,或者也可能使得某个线程的调度优先级高于其他线程,从而导致饥饿。

4.要避免使用线程优先级,因为这会增加平台依赖性,并可能导致活跃性问题。你经常能发现某个程序会在一些奇怪的地方调用Thread.sleep或Thread.yield,这是因为该程序试图克服优先级调整问题或响应性问题,并试图让低优先级的线程执行更多的时间。在大多数并发应用程序中,都可以使用默认的线程优先级。

10.3.2 糟糕的响应性

不良的锁管理可能导致糟糕的响应性。如果某个线程长时间占有一个锁(或许正在对一个大容器进行迭代,并且对每个元素进行计算密集的处理),而其他想要访问这个容器的线程就必须等待很长时间。

10.3.3 活锁

活锁:当多个相互协作的线程都对彼此进行响应从而修改各自的状态,并使得任何一个线程都无法继续执行时,就发生了活锁

1.活锁是另一种形式的活跃性问题,该问题尽管不会阻塞线程,但也会导致程序不能继续执行。因为线程将不断重复执行相同的操作,而且总会失败。

2.活锁通常发生在处理事务消息的应用程序中:如果不能成功的处理某个消息,那么消息处理机制将回滚整个事务,并将它重新放到队列的开头。如果消息处理器在处理某种特定类型的消息时存在错误并导致它失败,那么每当这个消息从队列中取出并传递到存在错误的处理器时,都会发生事务回滚。由于这条消息又被放回到队列开头,因此处理器将被反复调用,并返回相同的结果。(有时候也被称为毒药消息,Poison Message。)虽然处理消息的线程并没有阳塞,但也无法继续执行下去。这种形式的活锁通常是由过度的错误恢复代码造成的,因为它错误地将不可修复的错误作为可修复的错误。

3.活锁就像两个过于礼貌的人在半路上面对面地相遇:他们彼此都让出对方的路,然而又在另一条路上相遇了。因此他们就这样反复地避让下去。要解决这种活锁问题,需要在重试机制中引入随机性。例如,在网络上,如果两台机器尝试使用相同的载波来发送数据包,那么这些数据包就会发生冲突。这两台机器都检查到了冲突,并都在稍后再次重发。如果二者都选择了在1秒钟后重试,那么它们又会发生冲突,并且不断地冲突下去,因而即使有大量闲置的带宽,也无法使数据包发送出去。为了避免这种情况发生,需要让它们分别等待一段随机的时间。(以太协议定义了在重复发生冲突时采用指数方式回退机制,从而降低在多台存在冲突的机器之间发生拥塞和反复失败的风险。)在并发应用程序中,通过等待随机长度的时间和回退可以有效地避免活锁的发生。

小结

1.活跃性故障是一个非常严重的问题,因为当出现活跃性故障时,除了中止应用程序之外没有其他任何机制可以帮助从这种故障时恢复过来。最常见的活跃性故障就是锁顺序死锁。

2.在设计时应该避免产生锁顺序死锁:确保线程在获取多个锁时采用一致的顺序。最好的解决方法是在程序中始终使用开放调用。这将大大减少需要同时持有多个锁的地方,也更容易发现这些地方。

十一.性能与可伸缩性

线程的主要目的是提高程序的运行性能。线程可以充分利用系统的可用处理能力,从而提高系统资源的利用率。此外,线程还可以使程序在运行现有任务的情况下立即开始处理新的任务,从而提高系统的响应性

11.1 对性能的思考

资源密集型:当操作性能由于某种特定的资源而受到限制时,我们通常将该操作称为资源密集型的操作,例如,CPU密集型、数据库密集型等。CPU消耗少

计算密集型:要进行大量的计算,消耗CPU资源

要想通过并发来获得更好的性能,需要努力做好两件事情:更有效地利用现有处理资源,以及在出现新的处理资源时使程序尽可能地利用这些新资源。从性能监视的视角来看,CPU需要尽可能保持忙碌状态。(当然,这并不意味着将CPU时钟周期浪费在一些无用的计算上,而是执行一些有用的工作。)如果程序是计算密集型的,那么可以通过增加处理器来提高性能。因为如果程序无法使现有的处理器保持忙碌状态,那么增加再多的处理器也无济于事。通过将应用程序分解到多个线程上执行,使得每个处理器都执行一些工作,从而使所有CPU都保持忙碌状态。

11.1.1 性能与可伸缩性

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

1.应用程序的性能可以采用多个指标来衡量,例如服务时间、延迟时间、吞吐率、效率、可伸缩性以及容量等。

2.服务时间、等待时间一般用于衡量程序的“运行速度”,即某个指定的任务单元需要多快才能处理完。

3.生产量、吞吐量用于程序的处理能力,即在计算资源一定的情况下能完成多少工作

4.性能的这两个方面“多快”和“多少”,是完全独立的,有时候甚至是相互矛盾的。要实现更高的可伸缩性或硬件利用率,通常会增加各个任务所要处理的工作量,例如把任务分解为多个“流水线”子任务时。具有讽刺意味的是,大多数提高单线程程序性能的技术,往往
都会破坏可伸缩性。

5.对于服务器应用程序来说,”多少“比“多快”往往更受重视。通常会很接受每个工作单元执行更长的时间或消耗更多的计算资源,以换取应用程序在增加更多资源的情况下处理更高的负载

11.2 Amdahl定律

加速比:是同一个任务在单处理器系统和并行处理器系统中运行消耗的时间的比率,用来衡量并行系统或程序并行化的性能和效果

1.在有些问题中,如果可用资源越多,那么问题的解决速度就越快。例如,如果参与收割庄稼的工人越多,那么就能越快地完成收割工作。而有些任务本质上是串行的,例如,即使增加再多的工人也不可能增加作物的生长速度。如果使用线程主要是为了发挥多个处理器的处理能力,那么就必须对问题进行合理的并行分解,并使得程序能有效地使用这种潜在的并行能力。

2.大多数并发程序都与农业耕作有着许多相似之处,它们都是由一系列的并行工作和串行工作组成的。Amdahl定律描述的是:在增加计算资源的情况下,程序在理论上能够实现最高加速比,这个值取决于程序中可并行组件与串行组件所占的比重。假定F是必须被串行执行的部分,那么根据Amdahl定律,在包含N个处理器的机器中,最高的加速比为:

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

3.当N趋近无穷大时,最大的加速比趋近于1/F。因此,如果程序有50%的计算需要串行执行,那么最高的加速比只能是2 (而不管有多少个线程可用);如果在程序中有10%的计算需要串行执行,那么最高的加速比将接近10。Amdahl 定律还量化了串行化的效率开销。在拥有10个处理器的系统中,如果程序中有10%的部分需要串行执行,那么最高的加速比为5.3(53%的使用率),在拥有100个处理器的系统中,加速比可以达到9.2 (9%的使用率)。即使拥有无限多的CPU,加速比也不可能为10。

11.2.1 上下文切换

1.切换上下文需要一定的开销,而在线程调度过程中需要访问由操作系统和JVM共享的数据结构。应用程序、操作系统以及JVM都使用一组相同的CPU。在JVM和操作系统的代码中消耗越多的CPU时钟周期,应用程序的可用CPU时钟周期就越少。但上下文切换的开销并不只是包含JVM和操作系统的开销。当一个新的线程被切换进来时,它所需要的数据可能不在当前处理器的本地缓存中,因此上下文切换将导致一些缓存缺失,因而线程在首次调度运行时会更加缓慢。这就是为什么调度器会为每个可运行的线程分配一个最小执行时间,即使有许多其他的线程正在等待执行:它将上下文切换的开销分摊到更多不会中断的执行时间上,从而提高整体的吞吐量(以损失响应性为代价)。

2.当线程由于等待某个发生竞争的锁而被阻塞时,JVM通常会将这个线程挂起,并允许它被交换出去。如果线程频繁地发生阻塞,那么它们将无法使用完整的调度时间片。在程序中发生越多的阻塞(包括阻塞I/O,等待获取发生竞争的锁,或者在条件变量.上等待),与CPU密集型的程序就会发生越多的,上下文切换,从而增加调度开销,并因此而降低吞吐量。(无阻塞算法在一定程度上会有助于减少上下文切换)

3.在大多数处理器中,上下文的开销相当于5000-10000个时钟周期,也就是几微妙

11.2.2 内存同步

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

2.在评估同步操作带来的性能影响时,区分有竞争的同步和无竞争的同步非常重要。synchronized机制针对无竞争的同步进行了优化(volatile 通常是非竞争的),一个“快速通道(Fast-Path)”的非竞争同步将消耗20~250个时钟周期。虽然无竞争同步的开销不为零,但它对应用程序整体性能的影响微乎其微。

3.现代的JVM能通过优化来去掉一些不会发生竞争的锁,从而减少不必要的同步开销。如果一个锁对象只能由当前线程访问,那么JVM就可以通过优化来去掉这个锁获取操作,因为另一个线程无法与当前线程在这个锁上发生同步

4.某个线程中的同步可能会影响其他线程的性能。同步会增加共享内存总线上的通信量,总线的带宽是有限的,并且所有的处理器都将共享这条总线。如果有多个线程竞争同步带宽,那么所有使用了同步的线程都会受到影响

11.2.3 阻塞

1.非竞争的同步可以完全在JVM中进行处理,而竞争的同步可能需要操作系统的介人,从而增加开销。当在锁上发生竞争时,竞争失败的线程肯定会阻塞。JVM在实现阻塞行为时,可以采用自旋等待(Spin-Waiting, 指通过循环不断地尝试获取锁,直到成功)或者通过操作系统挂起被阻塞的线程。这两种方式的效率高低,要取决于上下文切换的开销以及在成功获取锁之前需要等待的时间。如果等待时间较短,则适合采用自旋等待方式,而如果等待时间较长,则适合采用线程挂起方式。有些JVM将根据对历史等待时间的分析数据在这两者之间进行选择,但是大多数JVM在等待锁时都只是将线程挂起。

2.当线程无法获取某个锁或者由于在某个条件等待或在I/O操作上阻塞时,需要被挂起,在这个过程中将包含两次额外的上下文切换,以及所有必要的操作系统操作和缓存操作:被阻塞的线程在其执行时间片还未用完之前就被交换出去,而在随后当要获取的锁或者其他资源可用时,又再次被切换回来。(由于锁竞争而导致阻塞时,线程在持有锁时将存在一定的开销:当它释放锁时,必须告诉操作系统恢复运行阻塞的线程。)

11.3 减少锁的竞争

1.串行操作会减低可伸缩性,上下文切换也会减低性能。当锁上发生竞争时将同时导致这两种问题,因此减少锁的竞争能够提高性能和可伸缩性。

2.在并发程序中,对可伸缩性的最主要威胁就是独占方式的资源锁(代码会串行执行)

3.有两个因素将影响在锁上发生竞争的可能性:锁的请求频率,以及每次持有该锁的时间。如果二者的乘积很小,那么大多数获取锁的操作都不会发生竞争,因此在该锁上的竞争不会对可伸缩性造成严重影响。然而,如果在锁上的请求量很高,那么需要获取该锁的线程将被阻塞并等待。在极端情况下,即使仍有大量工作等待完成,处理器也会被闲置。

4.有三种方式可以降低锁的竞争程度

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

11.3.1 缩小锁的范围(快进快出,减少锁的持有时间)

1.降低发生竞争可能性的一种有效方式就是尽可能的缩短锁的持有时间。可以将一些与锁无关的代码移出同步代码块,尤其是那些开销比较大的操作,以及可能被阻塞的操作(比如I/O操作)

2.尽管缩小同步代码块能提高可伸缩性,但同步代码块也不能过小一-些需要采用原子方式执行的操作(例如对某个不变性条件中的多个变量进行更新)必须包含在一个同步块中。此外,同步需要一定的开销,当把一个同步代码块分解为多个同步代码块时(在确保正确性的情况下),反而会对性能提升产生负面影响。在分解同步代码块时,理想的平衡点将与平台相关,但在实际情况中,仅当可以将一些“大量”的计算或阻塞操作从同步代码块中移出时,才应该考虑同步代码块的大小(如果JVM执行锁粒度粗化操作,那么可能会将分解的同步代码块又重新合并起来)。

11.3.2 减少锁的粒度

1.另一种减小锁的持有时间的方式是降低线程请求锁的频率(从而减小发生竞争的可能性)。这可以通过锁分解和锁分段等技术来实现,在这些技术中将采用多个相互独立的锁来保护独立的状态变量,从而改变这些变量在之前由单个锁来保护的情况。这些技术能减小锁操作的粒度,并能实现更高的可伸缩性,然而,使用的锁越多,那么发生死锁的风险也就越高。

2.如果一个锁需要保护多个相互独立的状态变量,那么可以将这个锁分解为多个锁,并且每个锁只保护一个变量,从而提高可伸缩性,并最终降低每个锁被请求的频率。

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

4.每个性能安全的set都会使用一个不同的锁来保护其状态

11.3.3 锁分段

1.把一个竞争激烈的锁分解为两个锁时,这两个锁可能都存在激烈的竞争。虽然采用两个线程并发执行能提高一部分可伸缩性,但在一个拥有多个处理器的系统中,仍然无法给可伸缩性带来极大的提高。

2.在某些情况下,可以将锁分解技术进一步扩展为对一组独立对象上的锁进行分解,这种情况被称为锁分段。例如,在ConcurrentHashMap的实现中使用了一个包含16个锁的数组,每个锁保护所有散列桶的1/16,其中第N个散列桶由第(N mod 16)个锁来保护。假设散列函数具有合理的分布性,并且关键字能够实现均匀分布,那么这大约能把对于锁的请求减少到原来的1/16。正是这项技术使得ConcurrentHashMap能够支持多达16 个并发的写入器。(要使得拥有大量处理器的系统在高访问量的情况下实现更高的并发性,还可以进一步增加锁的数量,但仅当你能证明并发写入线程的竞争足够激烈并需要突破这个限制时,才能将锁分段的数量超过默认的16个。)

3.锁分段的一个劣势在于:与采用单个锁来实现独占访问相比,要获取多个锁来实现独占访问将更加困难并且开销更高。通常,在执行一个操作时最多只需获取一个锁,但在某些情况下需要加锁整个容器,例如当ConcurrentHashMap需要扩展映射范围,以及重新计算键值的散列值要分布到更大的桶集合中时,就需要获取分段所集合中所有的锁。

11.3.4 避免热点域

1.锁分解和锁分段技术都能提高可伸缩性,因为它们都能使不同的线程在不同的数据(或者同一个数据的不同部分),上操作, 而不会相互干扰。如果程序采用锁分段技术,那么一定要表现出在锁上的竞争频率高于在锁保护的数据上发生竞争的频率。如果一个锁保护两个独立变量X和Y,并且线程A想要访问X,而线程B想要访问Y,那么这两个线程不会在任何数据上发生竞争,即使它们会在同一个锁上发生竞争。

2.当每个操作都请求多个变量时,锁的粒度将很难降低。这是在性能与可伸缩性之间相互制衡的另一个方面,一些常见的优化措施,例如将一些反复计算的结果缓存起来,都会引入一些“热点域(Hot Field)”,而这些热点域往往会限制可伸缩性。当实现HashMap时,你需要考虑如何在size方法中计算Map中的元素数量。最简单的方法就是,在每次调用时都统计一次元素的数量。一种常见的优化措施是,在插入和移除元素时更新一个计数器,虽然这在put和remove等方法中略微增加了一些开销,以确保计数器是最新的值,但这将把size方法的开销从O(n)降低到0(1)。

3.在单线程或者采用完全同步的实现中,使用一个独立的计数能很好地提高类似size和isEmpty这些方法的执行速度,但却导致可伸缩性问题,因为每个修改map的操作都需要更新这个共享的计数器。即使使用锁分段技术来实现散列链,那么在对计数器的访问进行同步时,也会重新导致在使用独占锁时存在的可伸缩性问题。一个看似性能优化的措施(缓存size操作的结果)已经变成了一个可伸缩性问题。在这种情况下,计数器也被称为热点域,因为每个导致元素数量发生变化的操作都需要访问它。

4.ConcurrentHashMap 中的size将对每个分段进行枚举并将每个分段中的元素数量相加,而不是维护一个全局计数。为了避免枚举每个元素,ConcurrentHashMap 为每个分段都维护了一个独立的计数,并通过每个分段的锁来维护这个值。

11.3.5 一些替代独占锁的方法

1.降低竞争锁的影响的技术还有一种方式就是放弃使用独占锁,从而有助于使用-种友好并发的方式来管理共享状态。例如,使用并发容器、读-写锁、不可变对象以及原子变量。

2.原子变量提供了一种方式来降低更新”热点域“时的开销,例如静态计数器、序列发生器、或者对链表数据结构中头节点的引用。原子变量类提供了在整数或者对象引用上的细粒度原子操作,并使用了现代处理器中提供的底层并发原语。如果在类中只包含少量的热点域并且不会与其他变量参与到不变性条件中,那么用原子变量来替代他们能提高可伸缩性,但是不能完全消除

11.3.6 监测CPU的利用率

如果CPU没有得到充分的利用,那么需要找出其中的原因,通常有以下几种原因:

  • 负载不充足。测试的程序中可能没有足够多的负载,因而可以在测试时增加负载,并检查利用率、响应时间和服务时间等指标的变化。如果产生足够多的负载使应用程序达到饱和,那么可能需要大量的计算机能耗,并且问题可能在于客户端系统是否具有足够的能力,而不是被测试系统。

  • I/O密集。可以通过iostat或perfmon来判断某个应用程序是否是磁盘I/O密集型的,或者通过监测网络的通信流量级别来判断它是否需要高带宽。

  • 外部限制。如果应用程序依赖于外部服务,例如数据库或Web服务,那么性能瓶颈可能并不在你自己的代码中。可以使用某个分析工具或数据库管理工具来判断在等待外部服务的结果时需要多少时间。

  • 锁竞争。使用分析工具可以知道在程序中存在何种程度的锁竞争,以及在哪些锁上存在“激烈的竞争”。然而,也可以通过其他一些方式来获得相同的信息,例如随机取样,触发一些线程转储并在其中查找在锁上发生竞争的线程。如果线程由于等待某个锁而被阻塞,那么在线程转储信息中将存在相应的栈帧,其中包含的信息形如“waiting to lock monito…"。非竞争的锁很少会出现在线程转储中,而对于竞争激烈的锁,通常至少会有一个线程在等待获取它,因此将在线程转储中频繁出现。

11.4 减少上下文切换的开销

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

2.将可能被阻塞并且不会对结果产生影响的操作(比如打印日志)封装到其他线程可以降低服务时间(一个操作完成的时间),从而提高吞吐量

小结

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

十二.并发开发注意事项

1.不一致的同步。许多对象遵循的同步策略是,使用对象的内置锁来保护所有变量。如果某个域被频繁地访问,但并不是在每次访问时都持有相同的锁,那么这就可能表示没有一致地遵循这个同步策略。

2.调用Thread.run。在Thread中实现了Runnable,因此包含了一个run方法。然而,如果直接调用Thread.run,那么通常是错误的,而应该调用Thread. start。

3.未被释放的锁。与内置锁不同的是,执行控制流在退出显式锁的作用域时,通常不会自动释放它们。标准的做法是在一个finally 块中释放显式锁,否则,当发生Exception事件时,锁仍然处于未被释放的状态。

4.空的同步块。虽然在Java内存模型中,空同步块具有一定的语义,但它们总是被不正确地使用,无论开发人员尝试通过空同步块来解决何种问题,通常都存在一些更好的解决方案。

5.双重检查加锁。双重检查加锁是一种错误的习惯用法,其初衷是为了降低延迟初始化过程中的同步开销,该用法在读取一个共享的可变域时缺少正确的同步。

6.在构造函数中启动一个线程。如果在构造函数中启动一个线程,那么将可能带来子类化问题,同时还会导致this引用从构造函数中逸出。

7.通知错误。notify和notifyAll方法都表示,某个对象的状态可能以某种方式发生了变,化,并且这种方式将在相关条件队列上被阻塞的线程恢复执行。只有在与条件队列相关的状态发生改变后,才应该调用这些方法。如果在一个同步块中调用了notify或notifyAll,但没有修改任何状态,那么就可能出错

8.条件等待中的错误。当在一个条件队列上等待时, Object.wait 和Condition.await方法应该在检查了状态谓词之后,在某个循环中调用,同时需要持有正确的锁。如果在调用Object.wait和Condition.await方法时没有持有锁,或者不在某个循环中,或者没有检查某些状态谓词,那么通常都是一个错误。

9.对Lock和Condition的误用。将Lock作为同步块来使用通常是一种错误的用法,正如调用Condition.wait而不调用await(后者能够通过测试被发现,因此在第一次调用它时将抛出IllegalMonitorStateException)。

10.在休眠或者等待的同时持有一个锁。如果在调用Thread.sleep时持有一个锁,那么将导致其他线程在很长一段时间内无法执行,因此可能导致严重的活跃性问题。如果在调用Object.wait或Condition.await时持有两个锁,那么也可能导致同样的问题。

11.自旋循环。如果在代码中除了通过自旋(忙于等待)来检查某个域的值以外不做任何事情,那么将浪费CPU时钟周期,并且如果这个域不是volatile类型,那么将无法保证这种自旋过程能结束。当等待某个状态转换发生时,闭锁或条件等待通常是一种更好的技术。

第四部分高级主题

十三.显示锁

13.1 Lock与ReentrantLock

1.Lock类方法

  • lock获取锁:优先考虑获取锁,待获取锁成功后,才响应中断

  • lockInterruptibly:优先考虑响应中断,而不是响应锁的普通获取或重入获取

  • tryLock会尝试获得锁,如果没有获得返回false 调用线程不会阻塞 而lock 与 lockInterruptibly如果没有获得锁会阻塞到获得锁

  • unlock释放锁

  • newCondition返回绑定到此实例的新Condition实例

2.使用几次Lock锁时要在finally里面释放几次锁(lock锁不会自动清除锁)

13.1.1 轮询锁与定时锁

1.如果不能获得所有需要的锁,那么可以使用可定时的或可轮询的锁获取方式,从而使你重新获得控制权,它会释放已经获得的锁,然后重新尝试获取所有锁(或者至少会将这个失败记录到日志,并采取其他措施)。

2.在实现具有时间限制的操作时,定时锁同样非常有用。当在带有时间限制的操作中调用了一个阻塞方法时,它能根据剩余时间来提供一个时限。如果操作不能在指定的时间内给出结果,那么就会使程序提前结束。当使用内置锁时,在开始请求锁后,这个操作将无法取消,因此内置锁很难实现带有时间限制的操作。

13.1.2 可中断的锁获取操作

正如定时的锁获取操作能在带有时间限制的操作中使用独占锁,可中断的锁获取操作同样能在可取消的操作中使用加锁。在内置锁中实现可取消的任务比较复杂。lockInterruptibly方法能够在获得锁的同时保持对中断的响应,并且由于它包含在Lock中,因此无须创建其他类型的不可中断阻塞机制

13.1.3 非块结构的加锁

1.在内置锁中,锁的获取和释放等操作都是基于代码块的,释放锁的操作总是与获取锁的操作处于同一个代码块,而不考虑控制权如何退出该代码块。自动的锁释放操作简化了对程序的分析,避免了可能的编码错误。都是在某些场合需要更灵活的加锁规则

2.可以通过降低锁的粒度可以提高代码的可伸缩性。锁分段技术在基于散列的容器中实现了不同的散列链,以便使用不同的锁。我们可以通过采用类似的原则来降低链表中锁的粒度,即为每个链表节点使用一个独立的锁,使不同的线程能独立地对链表的不同部分进行操作。每个节点的锁将保护链接指针以及在该节点中存储的数据,因此当遍历或修改链表时,我们必须持有该节点上的这个锁,直到获得了下一个节点的锁,只有这样,才能释放前一个节点上的锁。(被称为连锁式加锁或者锁耦合)

13.2 公平性

1.在RcentrantLock的构造函数中提供了两种公平性选择:创建一个非公平的锁(默认)或者一个公平的锁。在公平的锁上,线程将按照它们发出请求的顺序来获得锁,但在非公平的锁上,则允许“插队”:当一个线程请求非公平的锁时,如果在发出请求的同时该锁的状态变为可用,那么这个线程将跳过队列中所有的等待线程并获得这个锁。非公平的ReentrantLock并不提倡“插队”行为,但无法防止某个线程在合适的时候进行“插队”。在公平的锁中,如果有另一个线程持有这个锁或者有其他线程在队列中等待这个锁,那么新发出请求的线程将被放入队列中。在非公平的锁中,只有当锁被某个线程持有时,新发出请求的线程才会被放入队列

2.当执行加锁操作时,公平性将由于在挂起线程和恢复线程时存在的开销而极大地降低性能。在实际情况中,统计上的公平性保证,确保被阻塞的线程能最终获得锁,通常已经够用了,并且实际开销也小得多。有些算法依赖于公平的排队算法以确保它们的正确性,但这些算法并不常见。在大多数情况下,非公平锁的性能要高于公平锁的性能。

3.在激烈竞争的情况下,非公平锁的性能高于公平锁的性能的一个原因是:在恢复一个被挂起的线程与该线程真正开始运行之间存在着严重的延迟。假设线程A持有一个锁,并且线程B请求这个锁。由于这个锁已被线程A持有,因此B将被挂起。当A释放锁时,B将被唤醒,因此会再次尝试获取锁。与此同时,如果C也请求这个锁,那么C很可能会在B被完全唤醒之前获得、使用以及释放这个锁。这样的情况是一种“双赢”的局面: B获得锁的时刻并没有推迟,C更早地获得了锁,并且吞吐量也获得了提高。

4.当持有锁的时间相对较长,或者请求锁的平均时间间隔较长,那么应该使用公平锁。在这些情况下,“插队”带来的吞吐量提升(当锁处于可用状态时,线程却还处于被唤醒的过程中)则可能不会出现。

5.内置锁不会提供确定的公平性保证

6.即使对于公平锁而言,可轮询的tryLock仍然会插队

13.3 在synchronized和ReentrantLock之间进行选择

1.与显式锁相比,内置锁仍然具有很大的优势。内置锁为许多开发人员所熟悉,并且简洁紧凑,而且在许多现有的程序中都已经使用了内置锁,如果将这两种机制混合使用,那么不仅容易令人困惑,也容易发生错误。

2.ReentrantLock 的危险性比同步机制要高,如果忘记在finally块中调用unlock,那么虽然代码表面上能正常运行,但实际上已经埋下了一颗定时炸弹,并很有可能伤及其他代码。仅当内置锁不能满足需求时,才可以考虑使用ReentrantLock.

3.在一些内置锁无法满足需求的情况下,ReentrantLock可以作为一种高级工具。当需要一些高级功能时才应该使用ReentrantLock,这些功能包括:可定时的、可轮询的与可中断的锁获取操作,公平队列,以及非块结构的锁。否则,还是应该优先使用synchronized.

4.内置锁可以在线程转储中看见相关的锁信息而ReentrantLock锁的信息不能出现在线程转储的信息中(java6中提供了一个管理和调试接口,锁可以通过该接口进行注册,这样ReentrantLock锁的相关加锁信息就可以出现在线程转储中)

5.未来内置锁的性能可能会进一步提升,功能可能也会有所添加,所以尽可能使用内置锁

6.Java线程转储和分析(jstack 命令)

13.4 读—写锁(ReadWriteLock)

1.在读写锁实现的加锁的策略中,允许多个读操作同时进行,但每次只允许一个写操作

2.读写锁是一种性能优化措施,在一些特定的情况下能实现更高的并发性。在实际情况中,对于在多处理器系统上被频繁读取的数据结构,读写锁能够提高性能。而在其他情况下,读写锁的性能比独占锁的性能要略差一些:这是因为它们的复杂性更高。如果要判断在某种情况下使用读写锁是否会带来性能提升,最好对程序进行分析。由于ReadWriteLock使用Lock来实现锁的读一写部分,因此如果分析结果表明读一写锁没有提高性能,那么可以很容易地将读一写锁换为独占锁。

3.读写锁的可选实现

  • 释放优先。当一个写入操作释放写入锁时,并且队列中同时存在读线程和写线程,那么应该优先选择读线程,写线程,还是最先发出请求的线程?

  • 读线程插队。如果锁是由读线程持有,但有写线程正在等待,那么新到达的读线程能否立即获得访问权,还是应该在写线程后面等待?如果允许读线程插队到写线程之前,那么将提高并发性,但却可能造成写线程发生饥饿问题。

  • 重入性。读取锁和写入锁是否是可重人的?

  • 降级。如果一个线程持有写人锁,那么它能否在不释放该锁的情况下获得读取锁?这可能会使得写人锁被“降级”为读取锁,同时不允许其他写线程修改被保护的资源。

  • 升级。读取锁能否优先于其他正在等待的读线程和写线程而升级为一个写入锁?在大多数的读写锁实现中并不支持升级,因为如果没有显式的升级操作,那么很容易造成死锁。(如果两个读线程试图同时升级为写人锁,那么二者都不会释放读取锁。会造成相互等待)

4.ReentrantReadWriteLock为这两种锁都提供了可重入的加锁语义。与ReentrantLock类似,ReentrantReadWriteLock在构造时也可以选择是一个非公平的锁(默认)还是一个公平的锁。在公平的锁中,等待时间最长的线程将优先获得锁。如果这个锁由读线程持有,而另一个线程请求写人锁,那么其他读线程都不能获得读取锁,直到写线程使用完并且释放了写入锁。在非公平的锁中,线程获得访问许可的顺序是不确定的。写线程降级为读线程是可以的,但从读线程升级为写线程则是不可以的(这样做会导致死锁)。

5.与ReentrantLock类似的是,ReentrantReadWriteLock中的写入锁只能有唯一的所有者,并且只能由获得该锁的线程来释放。在Java5.0中,读取锁的行为更类似于一个Semaphore而不是锁,它只维护活跃的读线程的数量,而不考虑它们的标识。在Java6中修改了这个行为:记录哪些线程已经获得了读者锁。

小结

1.与内置锁相比,显式的Lock提供了一些扩展功能,在处理锁的不可用性方面有着更高的灵活性,并且对队列行有着更好的控制。但ReentrantLock不能完全替代synchronized,只有synchronized无法满足需求时,才应该使用它。

2.读写锁允许多个读线程并发地访问被保护的对象,当访问以读取操作为主的数据结构时,它能提高程序的可伸缩性。

十四.构建自定义的同步工具

14.1 状态依赖性的管理

1.在单线程程序中调用一个方法时,如果某个基于状态的前提条件未得到满足(例如“连接池必须非空”),那么这个条件将永远无法成真。因此,在编写顺序程序中的类时,要使得这些类在它们的前提条件未被满足时就失败。但在并发程序中,基于状态的条件可能会由于其他线程的操作而改变:一个资源池可能在几条指令之前还是空的,但现在却变为非空的,因为另一个线程可能会返回一个元素到资源池。对于并发对象上依赖状态的方法,虽然有时候在前提条件不满足的情况下不会失败,但通常有一种更好的选择,即等待前提条件变为真。

2.依赖状态的操作可以一直阻塞直到可以继续执行,这比使它们先失败再实现起来要更为方便且更不易出错。内置的条件队列可以使线程一直阻塞,直到对象进入某个进程可以继续执行的状态,并且当被阻塞的线程可以执行时再唤醒它们。

14.1.1 条件队列

条件队列:传统队列的元素是一个个数据,而与之不同的是,条件队列中的元素是一个个正在等待相关条件的线程。

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

2.Object.wait会自动释放锁,并请求操作系统挂起当前线程,从而使其他线程能够获得这个锁并修改对象的状态。当被挂起的线程醒来时,它将在返回之前重新获取锁。从直观上来理解,调用wait 意味着“我要去休息了,但当发生特定的事情时唤醒我”,而调用通知方法就意味着“特定的事情发生了”。

14.2 使用条件队列

1.条件谓词是使某个操作成为状态依赖操作的前提条件。在有界缓存中,只有当缓存不为空时,take 方法才能执行,否则必须等待。对take方法来说,它的条件谓词就是“缓存不为空”,take方法在执行之前必须首先测试该条件谓词。同样,put 方法的条件谓词是“缓存不满”。条件谓词是由类中各个状态变量构成的表达式。

2.在条件等待中存在一种重要的三元关系,包括加锁、wait方法和一个条件谓词。在条件谓词中包含多个状态变量,而状态变量由一个锁来保护,因此在测试条件谓词之前必须先持有这个锁。锁对象与条件队列对象(即调用wait和notify等方法所在的对象)必须是同一个对象。

3.wait 方法将释放锁,阻塞当前线程,并等待直到超时,然后中断线程,或者通过一个通知被唤醒。

4.在唤醒进程后,wait在返回前还要重新获取锁。当线程从wait方法中被唤醒时,它在重新请求锁时不具有任何特殊的优先级,而要与任何其他尝试进人同步代码块的线程一起正常地在锁上进行竞争。

14.2.1 过早唤醒

1.虽然在锁、条件谓词和条件队列之间的三元关系并不复杂,但wait方法的返回并不一定意味着线程正在等待的条件谓词已经变成真了。

2.内置条件队列可以与多个条件谓词一起使用。当一个线程由于调用notifyAll而醒来时,并不意味该线程正在等待的条件谓词已经变成真了。另外,wait 方法还可以“假装”返回,而不是由于某个线程调用了notify。

3.当执行控制重新进人调用wait的代码时,它已经重新获取了与条件队列相关联的锁。现在条件谓词是不是已经变为真了?或许。在发出通知的线程调用notifyAll时,条件谓词可能已经变成真,但在重新获取锁时将再次变为假。在线程被唤醒到wait重新获取锁的这段时间里,可能有其他线程已经获取了这个锁,并修改了对象的状志。或者,条件谓词从调用wait起根本就没有变成真。你并不知道另一个线程为什么调用notify或notifyAll,也许是因为与同一条件队列相关的另一个条件谓词变成了真。“一个条件队列与多个条件谓词相关”是一种很常见的情况

4.每当线程从wait中唤醒时,都必须再次测试条件谓词,如果条件谓词不为真,那么就继续等待(或者失败)。由于线程在条件谓词不为真的情况下也可以反复地醒来,因此必须在一个循环中调用wait,并在每次迭代中都测试条件谓词。

5.当使用条件等待时:

  • 通常都有一个条件谓词:包括一些对象状态的测试,线程在执行前必须首先通过这些测试。

  • 在调用wait之前测试条件谓词,并且从wait中返回时再次进行测试。

  • 在一个循环中调用wait。

  • 确保使用与条件队列相关的锁来保护构成条件谓词的各个状态变量。

  • 当调用wait、notify 或notifyAll等方法时,一定要持有与条件队列相关的锁。

  • 在检查条件谓词之后以及开始执行相应的操作之前,不要释放锁。

14.2.2 丢失信号

丢失信号:线程等待一个已经发生的事件

线程必须等待一个已经为真的条件,但在开始等待之前没有检查条件谓词,这样就会导致线程将等待一个已经发生的事件,会让线程等待时间变得很长(等待另一个线程发出通知)

14.2.3 通知

1.每当在等待一个条件时,一定要确保在条件谓词变为真时通过某种方式发出通知

2.在条件队列API中有两个发出通知的方法,即notify和notifyAll。无论调用哪一个,都必须持有与条件队列对象相关联的锁。在调用notify 时,JVM会从这个条件队列上等待的多个线程中选择一个来唤醒,而调用notifyAll则会唤醒所有在这个条件队列,上等待的线程。由于在调用notify或notifyAll时必须持有条件队列对象的锁,而如果这些等待中线程此时不能重新获得锁,那么无法从wait返回,因此发出通知的线程应该尽快地释放锁,从而确保正在等待的线程尽可能快地解除阻塞。

3.由于多个线程可以基于不同的条件谓词在同一个条件队列上等待,因此如果使用notify而不是notifyAll,那么将是一种危险的操作,因为单一的通知很容易导致类似于信号丢失的问题。

4.在进行优化时应该遵循”首先使程序正确执行,然后才使其运行得更快“的原则

14.3 显示的Condition

1.Condition是一种广义的内置条件队列,一个Condition和一个Lock关联在一起,就像一个条件队列和一个内置锁相关联一样

2.Condition提供了比内置条件队列更丰富的功能:在每个锁上可以存在多个等待、条件等待可以是可中断的或不可中断的、基于时限的等待、公平或者非公平的队列操作

3.对于每个Lock可以有任意数量的Condition对象。Condition对象继承了相关Lock对象的公平性,对于公平的锁,线程会依照FIFO顺序从Condition.await中释放

4.当内置锁和内置队列不能满足当前的需求时才应该使用显示锁及显示队列

14.4 Synchronizer剖析

1.ReentrantLock、Semaphore在实现时都使用了一个共同的基类,即AbstractQueuedSynchronizer(AQS),这个类也是其他许多同步类的基类。AQS是一个用于构建锁和同步器的框架,许多同步器都可以通过AQS很容易并且高效地构造出来。不仅ReentrantLock和Semaphore是基于AQS构建的,还包括CountDownLatch、ReentrantReadWriteLock 、SynchronousQueue和FutureTask。

2.AQS解决了在实现同步器时涉及的大量细节问题,例如等待线程采用FIFO队列操作顺序。在不同的同步器中还可以定义一些灵活的标准来判断某个线程是应该通过还是需要等待。

3.基于AQS来构建同步器能带来许多好处。它不仅能极大地减少实现工作,而且也不必处理在多个位置上发生的竞争问题(在没有使用AQS来构建同步器时的情况)。

小结

1.要实现一个依赖状态的类-如果没有满足依赖状态的前提条件,那么这个类的方法必须阻塞,那么最好的方式是基于现有的库类来构建,例如Semaphore.BlockingQueue或CountDownLatch。然而,有时候现有的库类不能提供足够的功能,在这种情况下,可以使用内置的条件队列、显式的Condition对象或者AbstractQueuedSynchronizer来构建自己的同步器。

2.内置条件队列与内置锁是紧密绑定在一起的,这是因为管理状态依赖性的机制必须与确保状态一致性的机制关联起来。同样,显式的Condition与显式的Lock也是紧密地绑定到一起的,并且与内置条件队列相比,还提供了一个扩展的功能集,包括每个锁对应于多个等待线程集,可中断或不可中断的条件等待,公平或非公平的队列操作,以及基于时限的等待。

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

1.与基于锁的方案相比,非阻塞算法在设计和实现上都要复杂得多,但它们在可伸缩性和活跃性上却拥有巨大的优势。由于非阻塞算法可以使多个线程在竞争相同的数据时不会发生阻塞,因此它能在粒度更细的层次上进行协调,并且极大地减少调度开销。而且,在非阻塞算法中不存在死锁和其他活跃性问题。在基于锁的算法中,如果一个线程在休眠或自旋的同时持有一个锁,那么其他线程都无法执行下去,而非阻塞算法不会受到单个线程失败的影响。从Java5.0开始,可以使用原子变量类(例如AtomicInteger和AtomicReference)来构建高效的非阻塞算法。

2.即使原子变量没有用于非阻塞算法的开发,它们也可以用做一种“更好的volatile类型变量”。原子变量提供了与volatile类型变量相同的内存语义,此外还支持原子的更新操作,从而使它们更加适用于实现计数器、序列发生器和统计数据收集等,同时还能比基于锁的方法提供更高的可伸缩性。

15.1 锁的劣势

1.如果有多个线程同时请求锁,那么JVM就需要借助操作系统的功能,被阻塞的线程根据以前线程持有锁的时间将自旋等待或者被挂起。如果被挂起,当线程恢复执行的时候就必须等待其他线程执行完他们的时间片以后才能被调度执行。在挂起和恢复线程等过程中存在着很大的开销,并且通常存在着较长时间的中断。如果在基于锁的类中包含着细粒度的操作,那么当锁上存在着激烈的竞争时,调度开销与工作开销的比值将会非常高

2.当一个线程正在等待锁时,它不能做任何其他事情。如果一个线程在持有锁的情况下被延迟执行(例如发生了缺页错误、调度延迟,或者其他类似情况),那么所有需要这个锁的线程都无法执行下去。如果被阻塞线程的优先级较高,而持有锁的线程优先级较低,那么这将是一个严重的问题,也被称为优先级反转(Priority Inversion)。即使高优先级的线程可以抢先执行,但仍然需要等待锁被释放,从而导致它的优先级会降至低优先级线程的级别。如果持有锁的线程被永久地阻塞(例如由于出现了无限循环,死锁,活锁或者其他的活跃性故障),所有等待这个锁的线程就永远无法执行下去。

15.2 硬件对并发的支持

15.2.1 比较并交换(CAS)

1.CAS包含了3个操作数,需要读写的内存位置V、进行比较的值A和要写入的值S。当V的值等于A时通过原子的方式将V的值替换为S的值,并且不管V的值是否等于A都返回V的值

2.当多个线程尝试使用CAS同时更新同一个变量时,只有其中一个线程能更新变量的值,而其他线程都将失败。然而,失败的线程并不会被挂起(这与获取锁的情况不同:当获取锁失败时,线程将被挂起),而是被告知在这次竞争中失败,并可以再次尝试。由于一个线程在竞争CAS时失败不会阻塞,因此它可以决定是否重新尝试,或者执行一些恢复操作,也或者不执行任何操作。这种灵活性就大大减少了与锁相关的活跃性风险(尽管在一些不常见的情况下仍然存在活锁风险)。

3.CAS的主要缺点是,它需要调用者处理竞争问题带来的问题,比如发生竞争后的重试、回退、放弃,而在锁中能自动处理竞争带来的问题(比如在获得锁之前保存阻塞)

4.CAS的性能会随着处理器数量的不同而发生较大的变化。在单CPU中CAS只需要很少的时钟周期,因为不需要处理器之间的同步。在非竞争的多CPU中需要10到150个时钟周期的开销。在大多数处理器上,在无竞争的锁竞争上获取和释放上的开销大于是CAS开销的两倍

15.3 原子变量

1.原子变量类共有12个,可分为4组:标量类(Scalar)、 更新器类、数组类以及复合变量类。

  • 最常用的原子变量就是标量类: AtomicInteger、 AtomicLong、 AtomicBoolean 以及AtomicReference。所有这些类都支持CAS,此外,AtornicInteger 和AtomicLong还支持算术运算。(要想模拟其他基本类型的原子变量,可以将short或byte等类型与int类型进行转换,以及使用floatToIntBits 或doubleToLongBits来转换浮点数。)

  • 原子数组类(只支持Integer、Long和Reference版本)中的元素可以实现原子更新。原子数组类为数组的元素提供了volatile类型的访问语义,这是普通数组所不具备的特性,volatile类型的数组仅在数组引用上具有volatile语义,而在其元素上则没有。

  • 尽管原子的标量类扩展了Number类,但并没有扩展基本类型的包装类,例如Integer或Long。事实上,它们也不能进行扩展:基本类型的包装类是不可修改的,而原子变量类是可修改的。在原子变量类中同样没有重新定义hashCode或equals方法,每个实例都是不同的。与其他可变对象相同,它们也不宜用做基于散列的容器中的键值。

2.compareAndSet判断是否相等时 对于int 会使用Integer.valueOf进行转化(Integer.valueOf转化时-128~128的值会使用缓存地址相等 其他的new一个Integer出来,地址不相等)

3.在中低程度的竞争下,原子变量能提供更高的可伸缩性,而在高强度的竞争下,锁能够更有效地避免竞争。(在单CPU的系统上,基于CAS的算法在性能上同样会超过基于锁的算法,因为CAS在单CPU的系统上通常能执行成功,只有在偶然情况下,线程才会在执行读-改-写的操作过程中被其他线程抢占执行。)

4.hashMap键值类型

15.4 非阻塞算法

非阻塞算法:如果在某个算法中,一个线程的失败或挂起不会导致其他线程也失败或挂起,那么这种算法就被称为非阻塞算法。

无锁算法:如果在算法的每个步骤中都存在某个线程能够执行下去,那么这个算法也被称为无锁算法。

如果在算法中仅将CAS用于协调线程之间的操作,并且能正确地实现,那么它既是一个无阻塞算法又是一个无锁算法。

1.在基于锁的算法中可能会发生各种各样的活跃性故障。如果线程在持有锁时由于阻塞I/O,内存页缺失或其他延迟而导致推迟执行,那么很可能所有线程都不能继续执行下去。

2.在非阻塞算法中通常不会出现死锁和优先级反转的问题,但是可能会出现饥饿和活锁问题(可能会反复执行CAS操作)

3.在实现相同的功能的前提下,非阻塞的算法通常比基于锁的算法更为复杂。创建非阻塞算法的关键在于,找出如何将原子修改的范围缩小到单个变量上,同时还要维护数据的一致性。

15.4.1 线性表

线性表:就是一种连续或间断存储的数组,这里的连续和间断是针对物理内存空间中线性表元素之间是否连续。

顺序表:连续数组对应内置数组的实现方式,被称为顺序表实现;

链表:间断数组对应的是指针的实现方式,这种方式也称为链表实现。保存的数据在内存中不连续的,用指针对数据进行访问。

栈:是一种数据结构,只能在一端进行插入和删除操作的特殊线性表,按照先进后出 (FILO)的原则存储数据。栈有栈底和栈顶,元素从栈顶出。

队列:是一种数据结构,其特点是先进先出,后进后出,只能在队首删除,在队尾增加。

1.栈的结构特性:栈可以动态增长和缩减,即(一般)可以向一个栈添加或从一个栈删除元素,但这种添加和删除操作只能从栈的栈顶进行操作,这种限制也造就了栈的先进后出特性。一般我们可以用顺序表和链表两种方式来实现栈,但是,根据栈的特性,其实还可以用其他结构来实现栈,只要这种结构能实现栈的先进后出,而且只能从栈的栈顶进行插入和删除操作。

2.队列的结构特性:队列是先进先出的线性表,它同时维护表的两端,但只能在表尾进行插入,在表头进行删除操作,这是有队列的先进先出特性决定的。所以,不管是用顺序表还是用链表实现队列,都需要遵循队列的先进先出特性。

3.顺序表、单链表、双链表、循环链表、有序表

15.4.2 原子更新器

AtomicReferenceFieldUpdater:一个基于反射的工具类,它能对指定类的指定的非private的volatile引用字段进行原子更新。

1.通过调用AtomicReferenceFieldUpdater的静态方法newUpdater就能创建它的实例,该方法要接收三个参数:

  • 包含该字段的对象的类

  • 将被更新的对象的类

  • 将被更新的字段的名称

2.其它的类还包括AtomicLongFieldUpdater、AtomicIntegerFieldUpdater

15.4.3 ABA

ABA问题是一种异常现象:如果在算法中的节点可以被循环使用,那么在使用“比较并交换”指令时就可能出现这种问题(主要在没有垃圾回收机制的环境中)。在CAS操作中将判断“V的值是否仍然为A”,并且如果是的话就继续执行更新操作。在大多数情况下,这种判断是完全足够的。然而,有时候还需要知道“自从上次看到V的值为A以来,这个值是否发生了变化?”在某些算法中,如果V的值首先由A变成B,再由B变成A,那么仍然被认为是发生了变化,并需要重新执行算法中的某些步骤。为解决这个问题,我们可以选择更新两个值,包括一个引用和一个版本号。AtomicStampedReferenc和AtomicMarkableReference支持在两个变量上执行原子的条件更新

小结

1.非阻塞算法通过底层的并发原语(例如比较并交换而不是锁)来维持线程的安全性。这些底层的原语通过原子变量类向外公开,这些类也用做一种“更好的volatile变量”,从而为整数和对象引用提供原子的更新操作。

2.非阻塞算法在设计和实现时非常困难,但通常能够提供更高的可伸缩性,并能更好地防止活跃性故障的发生。在JVM从一个版本升级到下一个版本的过程中,并发性能的主要提升都来自于(在JVM内部以及平台类库中)对非阻塞算法的使用。

十六.Java内存模型

16.1 内存模型

1.如果一个线程对一个变量赋值 aVariable=3;内存模型需要解决这样的问题:“在什么条件下,读取aVariabl的线程将看到这个值为3”这听起来似乎是一个愚蠢的问题,但如果缺少同步,那么将会有许多因素使得线程无法立即甚至永远,看到另一个线程的操作结果。在编译器中生成的指令顺序,可以与源代码中的顺序不同,此外编译器还会把变量保存在寄存器而不是内存中;处理器可以采用乱序或并行等方式来执行指令;缓存可能会改变将写人变量提交到主内存的次序;而且,保存在处理器本地缓存中的值,对于其他处理器是不可见的。这些因素都会使得一个线程无法看到变量的最新值,在没有使用正确的同步的情况下会导致其他线程中的内存操作似乎在乱序执行。

2.在单线程环境中,我们无法看到所有这些底层技术,它们除了提高程序的执行速度外,不会产生其他影响。Java语言规范要求JVM在线程中维护一种类似串行的语义:只要程序的最终结果与在严格串行环境中执行的结果相同,那么,上述所有操作都是允许的。这确实是一件好事情,因为在最近几年中,计算性能的提升在很大程度上要归功于这些重新排序措施。当然,时钟频率的提供同样提升了性能,此外还有不断提升的并行性–采用流水线的超标量执行单元,动态指令调度,猜测执行以及完备的多级缓存。随着处理变得越来越强大,编译器也在不断地改进:通过对指令重新排序来实现优化执行,以及使用成熟的全局寄存器分配算法。由于时钟频率越来越难以提高,因此许多处理器制造厂商都开始转而生产多核处理器,因为能够提高的只有硬件并行性。

3.在多线程环境中,维护程序的串行性将导致很大的性能开销。对于并发应用程序中的线程来说,它们在大部分时间里都执行各自的任务,因此在线程之间的协调操作只会降低应用程序的运行速度,而不会带来任何好处。只有当多个线程要共享数据时,才必须协调它们之间的操作,并且JVM依赖程序通过同步操作来找出这些协调操作将在何时发生。

16.1.1 平台的内存模型

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

2.要想确保每个处理器都能在任意时刻知道其他处理器正在进行的工作,将需要非常大的开销。在大多数时间里,这种信息是不必要的,因此处理器会适当放宽存储一致性保证,以换取性能的提升。在架构定义的内存模型中将告诉应用程序可以从内存系统中获得怎样的保证,此外还定义了一些特殊的指令(称为内存栅栏或栅栏),当需要共享数据时,这些指令就能实现额外的存储协调保证。为了使Java开发人员无须关心不同架构上内存模型之间的差异,Java 还提供了自己的内存模型,并且JVM通过在适当的位置.上插入内存栅栏来屏蔽在JMM与底层平台内存模型之间的差异。

3.在现代支持共享内存的多处理器(和编译器)中,当跨线程共享数据时,会出现一些奇怪的情况,除非通过使用内存栅栏来防止这些情况的发生。但是Java 程序不需要指定内存栅栏的位置,而只需通过正确地使用同步来找出何时将访问共享状态。

16.1.2 重排序

在没有充分同步的程序中,如果调度器采用不恰当的方式来交替执行不同线程的操作,那么将导致不正确的结果。更糟的是,JMM还使得不同线程看到的操作执行顺序是不同的,从而导致在缺乏同步的情况下,要推断操作的执行顺序将变得更加复杂。各种使操作延迟或者看似乱序执行的不同原因,都可以归为重排序。

16.1.3 Java内存模型简介

1.Java内存模型是通过各种操作来定义的,包括对变量的读写操作,监视器的加锁和释放操作,以及线程的启动和合并操作。JMM为程序中所有的操作定义了一个偏序关系,称之为Happens-Before。要想保证执行操作B的线程看到操作A的结果(无论A和B是否在同一个线程中执行),那么在A和B之间必须满足Happens-Before关系。如果两个操作之间缺乏Happens-Before关系,那么JVM可以对它们任意地重排序。

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

3.Java指令重排和happens-before规则

4.JVM内存模型(运行时数据区)

5.深入学习Java内存模型JMM

16.1.4 借助同步

1.由于Happens-Before的排序功能很强大,因此有时候可以“借助(Piggyback)” 现有同步机制的可见性属性。这需要将Happens-Before的程序顺序规则与其他某个顺序规则(通常是监视器锁规则或者volatile变量规则)结合起来,从而对某个未被锁保护的变量的访问操作进行排序。这项技术由于对语句的顺序非常敏感,因此很容易出错。它是一项高级技术,并且只有当需要最大限度地提升某些类(例如ReentrantLock)的性能时,才应该使用这项技术。

2.在类库中提供的其他Happens-Before排序包括:

  • 将一个元素放入一个线程安全容器的操作将在另一个线程从该容器中获得这个元素的操作之前执行。

  • 在CountDownLatch.上的倒数操作将在线程从闭锁上的await方法中返回之前执行。.

  • 释放Semaphore许可的操作将在从该Semaphore上获得一个许可之前执行。

  • Future表示的任务的所有操作将在从Future.get中返回之前执行。

  • 向Executor提交一个Runnable或Callable的操作将在任务开始执行之前执行。

  • 一个线程到达CyclicBarrier或Exchanger的操作将在其他到达该栅栏或交换点的线程被释放之前执行。如果CyclicBarrier使用-一个栅栏操作,那么到达栅栏的操作将在栅栏操作之前执行,而栅栏操作又会在线程从栅栏中释放之前执行。

16.2 发布

16.2.1 不安全的发布

1.当缺少Happens-Before关系时,就可能出现重排序问题,这就解释了为什么在没有充分同步的情况下发布一个对象会导致另一个线程看到一个只被部分构造的对象。在初始化一个新的对象时需要写入多个变量,即新对象中的各个域。同样,在发布一个引用时也需要写人一个变量,即新对象的引用。如果无法确保发布共享引用的操作在另一个线程加载该共享引用之前执行,那么对新对象引用的写入操作将与对象中各个域的写入操作重排序(从使用该对象的线程的角度来看)。在这种情况下,另一个线程可能看到对象引用的最新值,但同时也将看到对象的某些或全部状态中包含的是无效值,即一个被部分构造对象。

2.除了不可变对象以外,使用被另一个线程初始化的对象通常都是不安全的,除非对象的发布操作是在使用该对象的线程开始使用之前执行。

16.2.2 安全的发布

1.如果线程A将X放入BlockingQueue (并且随后没有线程修改它),线程B从队列中获取X,那么可以确保B看到的X与A放入的X相同。这是因为在BlockingQueue的实现中有足够的内部同步确保了put方法在take方法之前执行。同样,通过使用一个由锁保护共享变量或者使用共享的volatile类型变
量,也可以确保对该变量的读取操作和写人操作按照Happens-Before关系来排序。

2.Happens-Before 比安全发布提供了更强可见性与顺序保证。如果将X从A安全地发布到B,那么这种安全发布可以保证X状态的可见性,但无法保证A访问的其他变量的状态可见性(A线程对非X对象的其他变量修改对B不保证可见性)。然而,如果A将X置人队列的操作 happens-before 线程B从队列中获取X的操作,那么B不仅能看到A留下的X状态(假设线程A或其他线程都没有对X再进行修改),而且还能看到A在移交X之前所做的任何操作(如果移交之后修改了X的值不保证对B的可见性)。

3.既然JMM已经提供了这种更强大的Happens-Before 关系,那么为什么还要介绍@GuardedBy和安全发布呢?与内存写入操作的可见性相比,从转移对象的所有权以及对象公布等角度来看,它们更符合大多数的程序设计。Happens-Before 排序是在内存访问级别上操作的,它是一种“并发级汇编语言”,而安全发布的运行级别更接近程序设计。

16.2.3 安全初始化模式

Java对象的安全发布

16.3 初始化过程中的安全性

1.如果能确保初始化过程的安全性,那么就可以使得被正确构造的不可变对象在没有同步的情况下也能安全地在多个线程之间共享,而不管它们是如何发布的,甚至通过某种数据竞争来发布。

2.如果不能确保初始化的安全性,那么当在发布或线程中没有使用同步时,一些本应为不可变对象(例如String)的值将会发生改变。安全性架构依赖于String的不可变性,如果缺少了初始化安全性,那么可能会导致一个安全漏洞,从而使恶意代码绕过安全检查。

3.初始化安全性将确保,对于被正确构造的对象,所有线程都能看到由构造函数为对象给各个final域设置的正确值,而不管采用何种方式来发布对象。而且,对于可以通过被正确构造对象中某个final域到达的任意变量(例如某个final数组中的元素,或者由一个final域引用的HashMap的内容)将同祥对于其他线程是可见的.

4.对于含有final域的对象,初始化安全性可以防止对对象的初始引用被重排序到构造过程之前。当构造函数完成时,构造函数对final域的所有写入操作,以及对通过这些域可以到达的任何变量的写人操作,都将被“冻结”,并且任何获得该对象引用的线程都至少能确保看到被冻结的值。对于通过final域可到达的初始变量的写人操作,将不会与构造过程后的操作一起被重排序。

5.的初始化安全性只能保证通过final域可达的值从构造过程完成时开始的可见性。对于通过非final域可达的值,或者在构成过程完成后可能改变的值,必须采用同步来确保可见性。

小结

Java内存模型说明了某个线程的内存操作在哪些情况下对于其他线程是可见的。其中包括确保这些操作是按照一种Happens-Before的偏序关系进行排序,而这种关系是基于内存操作和同步操作等级别来定义的。如果缺少充足的同步,那么当线程访问共享数据时,会发生一些非常奇怪的问题。然而,如果使用更高级的规则,例如@GuardedBy和安全发布,那么即使不考虑Happens-Before的底层细节,也能确保线程安全性。

并发性标注

类的标注

@ThreadSafe:表示该类是线程安全的。

@Immutable :表示类是不可变的,它包含了@ThreadSafe的含义。

@NotThreadSafe:表示该类不是线程安全的。是可选的,如果一个类没有标注为线程安全的,那么就应该加上它不是线程安全的,但如果想明确地表示这个类不是线程安全的,那么就可以使用@NotThreadSafe。

这些标注都是非侵入式的,它们对于使用者和维护人员来说都是有益的。使用者可以立即看出一个类是否是线程安全的,而维护人员也可以直接看到是否维持了线程安全性保证。对第三方来说,标准同样很有用:工具。静态的代码分析工具可以验证代码是否遵守了由标注指定的契约,例如验证被标注为@Immutable的类是否是不可变的。

域和方法的标注

1.在使用加锁的类中,应该说明哪些状态变量由哪些锁保护的,以及哪些锁被用于保护这些变量。一种造成不安全性的常见原因是:某个线程安全的类一直通过加锁来保护其状态,但随后又对这个类进行了修改,并添加了一些未通过锁来保护的新变量,或者没有使用正确加锁来保护现有状态变量的新方法。通过说明哪些变量由哪些锁来保护,有助于避免这些疏忽。

2.@GuardedBy (lock) 表示只有在持有了某个特定的锁时才能访问这个域或方法。参数lock表示在访问被标注的域或方法时需要持有的锁。lock的可能取值包括:

  • @GuardedBy (“this"),表示在包含对象上的内置锁(被标注的方法或域是该对象的成员)。

  • @GuardedBy (“fieldName"),表示与fieldName引用的对象相关联的锁,可以是一个隐式锁(对于不引用一个Lock的域),也可以是一个显式锁(对于引用了一个Lock的域)。

  • @GuardedBy (“Class Name.fieldName"),类似于@GuardedBy (“fieldName"),但指向在另一个类的静态域中持有的锁对象。

  • @GuardedBy (“methodName()"),是指通过调用命名方法返回的锁对象。

  • @GuardedBy (“ClassName.class"),是指命名类的类字面量对象。

3.通过@GuardedBy来标识每个需要加锁的状态变量以及保护该变量的锁,能够有助于代码的维护与审查,以及通过一些自动化的分析工具找出潜在的线程安全性错误。

发布了92 篇原创文章 · 获赞 4 · 访问量 5万+

猜你喜欢

转载自blog.csdn.net/DingKG/article/details/102857845
今日推荐