java并发编程实战——读书笔记

 value++; 包含三个独立操作:读取value,将value加1,并将计算结果写入value
如果错误的假设程序中的操作将按照某种特定顺序来执行,那么会存在各种可能的危险。

框架中如果有多线程并发性,那使用框架的应用程序代码也会遇到并发性问题,在代码中会访问应用程序的状态,所有访问这些状态的代码都应该考虑线程安全问题。
Timer\Servlet 、JSP \ RMI远程方法调用\Swing 和AWT 都会引入线程安全问题。

同步:synchronized, volatile,显式锁,原子变量
 解决同步问题:1不在线程之间共享该状态变量,2将状态变量修改为不可变的变量,3在访问状态变量时使用同步。

程序状态的封装性越好,就越容易实现线程安全。

当多个线程访问某个类时,这个类始终都能表现出正确的行为,那么久称这个类是线程安全的。

在线程安全类中封装了必要的同步机制,因此客户端无须进一步采取同步措施。

无状态对象一定是线程安全的。
先检查后执行 是一种很典型的竞态条件。

内置锁是可重入的,就是同一个线程可以继续获取自己持有的锁。锁计数器+1,退出同步块时锁计数器-1,如果锁计数器为0,则该锁没有被任何线程持有。

JAVA的锁以线程为粒度,POSIX的锁以“调用”为粒度。

每个共享的和可变的变量都应该只由一个锁来保护,从而使维护人员知道是哪一个锁。
对于每个包含多个变量的不变性条件,其中涉及的所有变量都需要由同一个锁来保护。

应该尽量把执行时间较长又不影响同步状态的操作从同步代码块中分离出去,从而在这些操作执行过程中,其他线程可以访问共享方法。
在获取和释放锁操作上都需要一定的开销,因此同步代码块也不要分得过细。
同步代码块安全性必须保证,简单性和性能之间需要找到某种平衡。

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

JVM的“重排序”,可能使多线程程序出现不可预料的问题。

非volatile类型的long和double变量,JVM允许将64位的读操作或写操作分解为两个32位的操作。可能会读取到某个值的高32位和另一个值得低32位。64位操作系统上读写64位数值是原子操作。

volatile变量不会被缓存在寄存器或者对其他处理器不可见的地方。

volatile可以用来标示一些重要的程序生命周期事件的发生。仅当volatile变量能简化代码的实现以及对同步策略的验证时,才应该使用它们。
加锁机制既可以确保可见性又可以确保原子性,而volatile变量只能确保可见性。 count++ volatile不能确保原子性。
当且仅当满足以下条件时才使用volatile:
  • 对变量的写入操作不依赖变量的当前值,或者你能确保只有单线程更新变量的值。
  • 该变量不会与其他状态变量一起纳入不变性条件中。
  • 在访问变量时不需要加锁。
封装能够使得对程序的正确性进行分析变得可能,并且更难破坏设计约束条件。
不要在构造函数中使this引用逸出。因为当前this尚未构造完毕。
如果想在构造函数中注册一个事件监听器或启动线程,那么可以使用一个私有的构造函数和一个公共的工厂方法,从而避免不正确的构造过程。

线程封闭:
应用程序服务器提供的连接池是线程安全的。
局部变量和ThreadLocal类是线程封闭的。
使用线程封闭技术,可以把某个特定的子系统实现为单线程子系统。
ThreadLocal的get方法第一次调用时,会执行initialValue来获取初始值。
当某个频繁执行的操作需要一个临时对象,而同时又希望避免在每次执行时都重新分配该临时对象,这时就可以使用ThreadLocal。防止对可变的单实例变量或全局变量进行共享。当线程终止后,ThreadLocal的值会作为垃圾回收。
不变性:
不可变对象一定是线程安全的。
Volatile Cached Factorizer 实现线程安全。
安全发布:
在未被正确发布的对象中存在两个问题。手续,除了发布对象的线程外,其他线程可以看到的Holder域是一个失效值,因此将看到一个空引用或者之前的旧值。然后,更糟糕的情况是,线程看到Holder引用的值时最新的,但Holder状态的值却是失效的。情况变得更加不可预测的是,某个线程的第一次读取域时得到失效值,而再次读取这个域时会得到一个更新值。
Java内存模型为不可变对象的共享提供了一种特殊的初始化安全性保证。
要安全地发布一个对象,对象的引用以及对象的状态必须同时对其他对象线程可见。
  • 在静态初始化函数中初始化一个对象引用。
  • 将对象的引用保存到volatile类型的域或者AtomicReferance对象中。
  • 将对象的引用保存到某个正确构造对象的final类型域中。
  • 将对象的引用保存到一个由锁保护的域中。
线程封闭、只读共享、线程安全共享、保护对象。

实例封闭:
deepCopy并不只是用unmodifiableMap来包装Map的,因为这只能防止容器对象被修改,而不能防止调用者修改保存在容器中的可变对象。基于同样的原因,如果只是通过拷贝构造函数来填充deepCopy中的HashMap,那么同样是不正确的,因为这样做只是复制了只想Point对象的引用,而不是Point对象本身。
由于每次调用getLocation就要复制数据,虽然车辆的实际位置发生了变化,但返回的信息却保持不变。

如果一个类是由多个独立的线程安全的状态变量组成,并且在所有的操作中都不包含无效状态转换,那么可以将线程安全性委托给底层的状态变量。如果含有复合操作,则这个类必须提供自己的加锁机制以保证这些复合操作都是源自操作,除非整个复合操作都可以委托给状态变量。

扩展方法比直接将代码添加到类中更脆弱,因为现在的同步策略实现被分布到多个单独维护的源代码文件中。如果底层的类改变了同步策略并选择了不同的锁来保护它的状态变化,那么子类会被破坏。
通过组合的办法,为现有的类添加一个原子操作,更好。

在设计同步策略时需要考虑多个方面,例如:将哪些变量声明为volatile类型,哪些变量用锁来保护,哪些锁保护哪些变量,哪些变量必须是不可变的或者被封闭在线程中的,哪些操作必须是原子操作等。
java.text.SimpleDateFormat不是线程安全的。
将同步策略文档化。
Collections.synchronizedXxx 实现线程安全的方式:将它们的状态封装起来,并对每个公有方法都进行同步,使得每次只有一个线程能访问容器的状态。

如果不希望在迭代期间对容器加锁,那么一种替代方法就是“克隆”容器,并在副本上进行迭代。会存在显著的性能开销。这种方式的好坏取决于对个因素:容器的大小,在每个元素上执行的工作,迭代操作相对于容器其他操作的调用平率,以及在响应时间和吞吐量等方面的需求。

标准容器的toString方法将迭代容器,并在每个元素上调用toString来生成容器内容的格式化表示。
通过并发容器来代替同步容器,可以极大地提高伸缩性并降低风险。

ConcurrentHashMap使用一种粒度更细的加锁机制来实现更大程度的分享,分段锁。在这种机制下,任意数量的读取线程可以并发地访问Map,执行读取操作的线程和执行写入操作的线程可以并发的访问Map,并且一定数量的写入线程可以并发的修改Map。在并发访问环境下将实现更高的吞吐量,而在单线程下损失非常小的性能。它提供的迭代器不会跑出COncurrentModificationException,因此不需要在迭代过程中对容器加锁。
对于一些需要在整个Map上进行计算的方法,例如Size和isEmpty,这些方法的雨衣被略微减弱了以反映容器的并发特性。由于size返回的结果在计算时可能已经过期了,它是集上只是一个估计值。get、put、containKey和remove的性能更强了。
CopyOnWriteArrayList 的迭代器不会抛出ConcurrentModificationException,并且返回的元素与迭代器创建时的元素完全一致,而不必考虑之后修改操作所带来的影响。 可以用在事件通知系统中。

阻塞队列生产者消费者模式。 有时需要调整生产者线程数量和消费者线程数量之间的比率,从而实现更高的资源利用率。
put会阻塞,offer不会阻塞会返回一个状态。
在构建高可靠的应用程序时,有界队列是一种强大的资源管理工具,它们能抑制并防止产生过多的工作项,使应用程序在负荷过载的情况下变得更加健壮。
BlockingQueue、LinkedBlockingQueue、ArrayBlockingQueue是FIFO队列。PriorityBlockingQueue优先级队列。
SynchronousQueue 不会为队列中元素维护存储空间,它维护一组线程。这些线程在等待把元素加入或移除队列。put和take会一直阻塞,直到有另一个线程已经准备好参与到交付过程中。仅当有足够多的消费者,并且总是有一个消费者准备好获取交付工作时,才适合使用同步队列。
SynchronousQueue  它非常适合于传递性设计,在这种设计中,在一个线程中运行的对象要将某些信息、 事件或任务传递给在另一个线程中运行的对象,它就必须与该对象同步。
生产者和消费者可以并发的执行,如果一个是I/O密集型,另一个是CPU密集型,那么并发执行的吞吐率。如果生产者和消费者的并行度不同,那么将它们紧密耦合在一起会把整体并行度降低为二者中更小的并行度。
BlockingDeque 双端队列 的使用场景是工作密取, 当执行某个工作时可能导致出现更多的工作的场景。网页爬虫、搜索图算法,垃圾回收阶段对堆进行标记等。

CountDownLatch 闭锁,确保某个计算在其需要的所有资源都被初始化之后才继续执行。确保某个服务在其所依赖的所有其他服务都已经启动之后才启动。等待直到某个操作的所有参与者都就绪再继续执行。
CountDownLatch是一种灵活的闭锁实现,可以使一个或多个线程等待一组事件的发生。闭锁状态包括一个计数器,该计数器被初始化为一个正数,表示需要等待的事件数量。countDown方法递减计数器,表示有一个事件已经发生了,而await方法等待计数器达到零,这表示所有需要等待的事件都已经发生。如果计数器的值非零,那么await会一直阻塞直到计数器为零,或者等待中的线程中断,或者等待超时。

FutureTask
Future.get的行为取决于任务的状态。如果任务已经完成,那么get会立即返回结果,否则get将阻塞直到任务进入完成状态,然后返回结果或者抛出异常。
 Semaphore:用来控制同时访问某个特定资源的操作熟练,或者同时执行某个特定操作的数量。计数信号量还可以用来实现某种资源池,或者对容器施加边界。
Semaphore管理着一组虚拟的许可,许可的初始数量可通过构造函数来指定。在执行操作时可以首先获得许可,并在使用以后释放许可。如果没有许可,那么acquire将阻塞直到有许可。release方法将返回一个许可给信号量。初始值为1的Semaphore可以用做互斥体,并具备不可重入的加锁语义:谁拥有这个唯一的许可,谁就拥有了互斥锁。

在不涉及I/O操作或共享数据访问的计算问题中,当现实数量为CPU或CPU+1时将获得最优的吞吐量。更多的线程并不会带来任何帮助,甚至在某种程度上会降低性能,因为多个线程将会在CPU和内存等资源上发生竞争。

CyclicBarrier可以使一定数量的参与方反复地在栅栏位置汇集,它在并行迭代算法中非常有用:这种算法通常将一个问题拆分成一系列相互独立的问题。当线程到达栅栏位置时将调用await方法,这个方法将阻塞直到所有线程都到达栅栏位置。如果所有线程都到达了栅栏位置,那么栅栏将打开,此时所有线程都被释放,而栅栏将被重置以便下次使用。如果对await的调用超时,或者await阻塞的线程被中断,那么栅栏就被认为是打破了,所有阻塞的await调用都将终止并抛出BrokenBarrierException。如果成功通过栅栏,那么await将为每个线程返回一个唯一的到达索引号,可以用来选举。
将一个问题分解成一定数量的子问题,为每个子问题分配一个线程来进行求解,之后再将所有结果合并起来。

当线程A调用Exchange对象的exchange()方法后,他会陷入阻塞状态,直到线程B也调用了exchange()方法,然后以线程安全的方式交换数据,之后线程A和B继续运行。
当两方执行不对称的操作时,Exchanger会非常有用。比如一个线程向缓冲区写入数据,另一个线程从缓冲区读取数据。将满的缓冲区与空的缓冲区交换。
数据交换的时机取决于应用程序的响应需求。最简单的方案是,当缓冲区被填满时,由填充任务进行交换,当缓冲区为空时,由清空任务进行交换。这样会把需要交换的次数降至最低,但如果新数据的到达率不可预测,那么一些数据的处理过程就将延迟。另一个方法是,不仅当缓冲区被填满时进行交换,并且当缓冲被填充到一定程度并保持一定时间后,也进行交换。

结构化并发应用程序

无限制创建线程的不足:线程生命周期的开销非常高、资源消耗(活跃的线程会消耗系统资源,尤其是内存)、稳定性(在可创建线程的数量上存在一个限制)。
Executor框架基于生产者-消费者模式。
Executor提供了对生命周期的支持,以及统计信息收集,应用程序管理机制和性能监视
  • 在什么线程中执行任务
  • 任务按照什么顺序执行(FIFO、LIFO、优先级)
  • 有多少个任务能并发执行
  • 在队列中有多少个任务在等待执行
  • 如果系统由于过载而需要拒绝一个任务,那么应该选择哪一个任务?另外,如何通知应用程序有任务被拒绝
  • 在执行一个任务之前或之后,应该进行哪些动作
各种执行策略都是一种资源管理工具,最佳策略取决于可用的计算资源以及对服务质量的需求。
Executors
newSingleThreadExecutor 单线程,能确保依照任务在队列中的顺序来串行执行(例如FIFO、LIFO、优先级)
单线程的Executor还提供了大量的内部同步机制,从而确保了任务执行的任何内存写入操作对于后续任务来说都是可见的。
ExecutorService在初始创建时处于运行状态。shutdown方法将执行平缓的关闭过程:不再接受新的任务,同时等待已经提交的任务执行完成——包括哪些还未开始运行的任务。shutdownNow方法将执行粗暴的关闭过程:它将尝试取消所有运行中的任务,并且不再启动队列中尚未开始执行的任务。
awaitTermination来等待ExecutorService到达终止状态,或者通过isTerminated来轮询。
Timer的调度是单线程的,所以一个任务执行时间过长会破坏其他TimerTask的定时精确性。TimerTask抛出一个未检查的异常时将终止定时线程。
ScheduledThreadPoolExecutor和DelayQueue 可以很好的处理定时调度。

Executor框架中,已提交但尚未开始的任务可以取消,已经开始执行的任务只有当他们能够响应中断时才可以取消。已完成的任务取消不会有任何影响。
Future的get方法的行为取决于任务的状态(尚未开始、正在运行、已完成)。如果任务已经完成,那么get会立即返回或者跑出一个Exception,如果任务没有完成,那么get将阻塞并指导任务完成。如果任务抛出了异常,那么get将该异常封装为ExecutionException并重新抛出。如果任务被取消,那么get将抛出CancellationException。如果get抛出了ExecutionException,那么可以通过getCause来获得被封装的初始异常。
当在多个工人之间分配异构的任务时,各个任务的大小可能完全不同。为了使任务分解能提高性能,任务协调开销不能高于并行性能的提升。只有当大量相互独立且同构的任务可以并发进行处理时,才能体现出将程序的工作负载分配到多个任务中带来的真正性能提升。
可以通过CompletionService从两个方面来提高页面渲染器的性能:缩短总运行时间以及提高响应性。
要想在将应用程序分解为不同的任务时获得最大的好处,必须定义清晰的任务边界。某些应用程序中存在着比较明显的任务边界,而在其他一些程序中则需要进一步分析才能揭示出粒度更细的并发性。
invokeAll,将多个任务提交到一个ExecutorService并获得结果。InvokeAll方法的参数为一组任务,并返回一组Future。这两个集合有着相同的结构。invokeAll按照任务结合中迭代器的顺序将所有的Future添加到返回的集合中,从而使调用者能将各个Future与其表示的Callable关联起来。当所有任务都执行完毕时,或者调用线程被中断时,又或者超过指定时限时,invokeAll将返回。当超过指定时限后,任何还未完成的任务都会取消。当invokeAll返回后,每个任务要么正常完成,要么被取消,而客户端代码可以调用get或isCancelled来判断究竟是何种情况。

取消与关闭
行为良好的软件能完善的处理失败、关闭和取消等过程。
取消某个操作的原因:
  • 用户取消请求
  • 有时间限制的操作  某个应用程序需要在有限时间内搜索问题空间,并在这个时间内选择最佳的解决方案。当计时器超时时,需要取消所有正在搜索的任务。
  • 应用程序事件: 应用程序对某个问题空间进行分解并搜索,从而使不同的任务可以搜索问题空间的不同区域。当其中一个任务找到了解决方案时,所有其他仍在搜索的任务都将被取消。
  • 错误: 当某个任务发生错误时,所有同类型的任务都取消。。
  • 关闭: 当一个程序或服务关闭时,必须对正在处理和等待处理的工作执行某种操作。
使用volatile类型的域来保存取消状态
一个可取消的任务必须包含:其他代码如何请求取消该任务,任务在什么时候检查“是否请求了取消”,以及在响应取消请求时应该执行哪些操作。
在使用volatile变量值来检测取消任务的方法中,如果任务调用了一个阻塞方法,可能永远不会检查取消状态。而且取消需要花费一定时间。
JVM不能保证阻塞方法检测到中断的速度,但在实际情况中响应速度还是非常快的。
调用interrupt并不是立即停止目标线程正在进行的工作,而只是传递了请求中断的消息。然后由线程在下一个合适的时刻中断自己。
例如:wait、sleep和join等,将严格的处理中断请求,当它们收到中断请求或者在开始执行时发现某个已被设置好的中断状态时,将抛出一个异常。设计良好的方法可以完全忽略这种请求,只要它们能使调用代码对中断请求进行某种处理。设计糟糕的方法可能会屏蔽中断请求,从而导致调用栈的其他代码无法对中断请求作出响应。
使用静态的interrupted时会清除当前线程的中断状态。如果返回true,正常必须恢复中断状态: Thread.currentThread().interrupt(); 
在很多情况下,主线程生成并起动了子线程,如果子线程里要进行大量的耗时的运算,主线程往往将于子线程之前结束,但是如果主线程处理完其他的事务后,需要用到子线程的处理结果,也就是主线程需要等待子线程执行完成之后再结束,这个时候就要用到join()方法了。
ExecutorService.submit提交的任务,可以通过返回的Future来取消任务。
当Future.get抛出InterruptedException或TimeoutException时,如果你知道不再需要结果,那么就可以调用Future.cancel来取消任务。
采用ThreadPoolExecutor的newTaskFor来封装非标准的取消任务。

ExecutorService的shutdownNow会尝试取消正在执行的任务,并返回所有未开始的任务,但不会返回已经开始但尚未结束的任务。
线程池的使用
当线程的ThreadLocal的生命周期受限于任务的生命周期时,在线程池的线程中使用ThreadLocal才有意义,而在线程池的线程中不应该使用ThreadLocal在任务之间传递值。
只有当任务都是同类型的并且相互独立时,线程池的性能才能达到最佳。如果将运行时间较长的与运行时间较短的任务混合在一起,那么除非线程池很大,否则将可能造成“拥塞”。如果提交的任务依赖于其他任务,那么除非线程池无限大,否则将可能造成死锁。
有些类型的任务需要明确地指定执行策略:依赖性任务、使用线程封闭机制的任务,对响应时间敏感的任务,使用ThreadLocal的任务。
线程池中的任务需要无限期地等待一些必须由池中一些其他任务才能提供的资源或条件,例如某个任务等地啊另一个任务的返回值或执行结果,那么有可能发生线程饥饿死锁。
每当提交一个有依赖性的Executor任务时,要清楚地知道可能出现线程饥饿死锁。
可以通过限定任务等待资源的时间,来缓解执行时间较长任务造成的影响。Thread.join、BlockingQueue.put、CountDownLatch.await、Selector.select 都有限时功能。
如果线程池过大,那么大量的线程将在相对很少的CPU和内存资源上发生竞争,这不仅会导致更高的内存使用量,而且还可能耗尽资源。如果线程池过小,那么将导致许多空闲的处理器无法执行工作,从而降低吞吐率。
要想正确地地设置线程池的大小,必须分析计算环境、资源预算和任务的特性。在部署的系统中有多少个CPU?多大的内存?任务是计算密集型、I/O密集型还是二者皆可?它们是否需要像JDBC连接这样的稀缺资源?如果需要执行不同类别的任务,并且它们之间的行为相差很大,那么应该考虑使用多个线程池,从而使每个线程池可以根据格子的工作负载来调整。
对于计算密集型的任务,在拥有N个CPU的系统上,当线程池的大小为N+1时,通常能实现最优的利用率。(即使当计算密集型的线程偶尔由于页缺失故障或者其他原因而暂停,这个额外的线程也能确保CPU的时钟周期不会被浪费。)对于包含I/O曹组哦或者其他阻塞的任务,由于线程并不会一直执行,因此线程池的规模应该更大。要正确的设置线程池的大小,你必须估算出任务的等待时间与计算时间的比值。

内存、文件句柄、套接字句柄和数据库连接等资源对线程池的约束条件:计算每个任务对该资源的需求量,然后用该资源的可用总量除以每个任务的需求量,所得结果就是线程池大小的上线。
线程池和资源池的大小将会相互影响。
Executor的newCachedThreadPool能提供比固定大小的线程池更好的排队性能。

调用者运行策略实现了一种调节机制,该策略既不会抛弃任务,也不会抛出异常,而是将某些任务回退到调用者,从而降低新任务的流量。它不会在线程池的某个线程中执行新提交的任务,而是在一个调用了execute的线程中执行任务。我们可以将WebServer修改为使用有界队列和“调用者运行”饱和策略,当线程池中的所有线程都被占用,并且工作队列被填满后,下一个任务会在调用execute时再主线程中执行。由于执行任务需要一定时间,因此主线程至少在一段时间内不能提交任何任务,从而使得工作者线程有时间来处理完正在执行的任务。在这期间,主线程不会调用accept,因此到达的请求将被保存在TCP层的队列中而不是在应用程序的队列中。如果持续过载,那么TCP层将最终发现它的请求队列被填满,因此同样会开始抛弃请求。当服务器过载时,这种过载情况会逐渐向外蔓延开来——从线程池到工作队列到应用程序再到TCP层,最终达到客户端,导致服务器在高负载下实现一种平缓的性能降低。

Executor的privilegedThreadFactory的线程工厂,创建出来的线程将与创建privilegedThreadFactory的线程拥有相同的访问权限、AccessControlContext和contextClassLoader。如果不使用privilegedThreadFactory,线程池创建的线程将从在需要新线程时调用execute或submit的客户程序中继承访问权限,从而导致令人困惑的安全性异常。

对ThreadPoolExecutor的扩展:改写beforeExecute、afterExecute和terminated。可以添加日志、计时、监视或统计信息收集的功能。
无论任务是从run中正常返回,还是抛出一个异常返回,afterExecute都会被调用。(如果任务在完成后带有一个Error,那么就不会调用afterExecute)如果beforeExecute抛出一个RuntimeException,那么任务将不被执行,并且afterExecute也不会被调用。

活跃性、性能与测试
如果所有线程以固定的顺序来获得锁,那么在程序中就不会出现锁顺序死锁问题。
要想验证锁顺序的一致性,需要对程序中的加锁行为进行全局分析。
在制定锁的顺序时,可以使用System.identityHashCode方法,Object的hashCode。保持一致性。
如果在持有锁时调用某个外部方法,那么将出现活跃性问题。在这个外部方法中可能会获取其他锁(着可能会产生死锁),或者阻塞时间过长,导致其他线程无法及时获得当前被持有的锁。
在程序中应尽量使用开放调用。不用synchronized锁住整个方法。这时候可能会丢失原子性。
在使用细粒度锁的程序中检查死锁的方法:首先,找出在什么地方将获取多个锁(使这个集合尽量小),然后对所有这些实例进行全局分析,从而确保它们在整个程序中获取锁的顺序都保持一致。
通常,我们尽量不要改变线程的优先级,只要改变了线程的优先级,程序的行为就将与平台相关,并且会发生导致饥饿问题的风险。

多线程开销:线程之间的协调(如加锁、触发信号、内存同步),增加的上下文切换,线程的创建和销毁,以及线程的调度等。
首先要保证程序能正常运行,然后仅当程序的性能需求和测试结果要求程序执行得更快时,才应该设法提高它的运行速度。在设计并发的应用程序时,最重要的考虑因素通常并不是将程序的性能提升至极限。
更有效地利用现有处理资源,以及在出现新的处理资源时使程序尽可能的利用这些新资源。

可伸缩性指的是:当增加计算资源时(例如CPU、内存、存储容量或I/O带宽),程序的吞吐量或者处理能力能相应的增加。
在大多数性能决策中都包含有多个变量,并且非常依赖于运行环境。在使某个方案比其他方案“更快”之前,首先问自己一些问题:
  • “更快”的含义是什么?
  • 该方法在什么条件下运行得更快?在低负载还是高负载的情况下?大数据集还是小数据集?能否通过测试结果来验证你的答案?
  • 这些条件在运行环境中的发生频率?能否通过测试结果来验证你的答案?
  • 在其他不同条件的环境中能否使用这里的代码?
  • 在实现这种性能提升时需要付出哪些隐含的代价,例如增加开发风险或维护开销?这种权衡是否合适?
随着处理器数量的增加,可以很明显的看到,即使串行部分所占的百分比很小,也会极大地限制当增加计算资源时能够提升的吞吐率。
大多数通用的处理器中,上下文切换的开销相当于5000~10000个时钟周期,也就是几微秒。
在程序中发生越多的阻塞(包括阻塞I/O,等待获取发生竞争的锁,或者在条件变量上等待),与CPU密集型的程序就会发生越多的上下文切换,从而增加调度开销,并因此而降低吞吐量。vmstat可以报告上下文切换次数以及所占时间的比例。如果内核占用率较高(超过10%),那么通常表示调度活动发生得很频繁,可能是由I/O或竞争锁导致的阻塞引起的。
当一个新的线程被切换进来时,它所需要的数据可能不在当前处理器的本地缓存中,因此上下文切换将导致一些缓存缺失,因而线程在首次调度运行时会更加缓慢。调度器会为每个可运行的线程分配一个最小执行时间。
当线程由于等待某个发生竞争的锁而被阻塞时,JVM通常会将这个线程挂起,并允许它被交换出去。如果线程频繁地发生阻塞,那么它们将无法使用完整的调度时间片。

在并发程序中,对可伸缩性的最主要威胁就是独占方式的资源锁。
有三种方式可以降低锁的竞争程度:
  • 减少锁的持有时间。
  • 降低锁的请求频率。
  • 使用带有协调机制的独占锁,这些机制允许更高的并发性。
仅当可以将一些“大量”的计算或阻塞操作从同步代码块中移除时,才应该考虑同步代码块的大小。
尽管缩小同步代码块能提高可伸缩性,但同步代码块也不能过小——一些需要采用原子方式执行的操作必须包含在一个同步块中。同步需要一定的开销,当把一个同步代码块分解为多个同步代码块时,反而会对性能提升产生负面影响。

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

在ConcurrentHashMap的实现中使用了一个包含16个锁的数组,每个锁保护所有散列通的1/16,其中第N个散列桶由第(N mod 16)个锁来保护。假设散列函数具有合理的分布性,并且关键字能够实现均匀分布,那么这大约能把对于锁的请求减少到原来的1/16。正是这项技术使得ConcurrentHashMap能够支持多达16个并发的写入器。
锁分段的劣势:与采用单个锁来实现独占访问相比,要获取多个锁来实现独占访问将更加困难并且开销更高。

CPU利用率不均匀,表明大多数计算都是由以小组线程完成的,并且应用程序没有利用其它的处理器。
通常对象分配操作的开销比同步的开销更低。
对象池有其特定的用途,但对于性能优化来说,用途是有限的。

减少上下文切换开销的一个例子——日志系统

如果日志模块将I/O操作从发出请求的线程转移到另一个线程,那么通常可以提高性能,但也会引入更多的设计复杂性,例如中断(当一个在日志操作中阻塞的线程被中断,将出现什么情况)、服务担保(日志模块能否保证队列中的日志消息都能在服务结束之前记录到日志文件)、饱和策略(当日志消息的产生速度比日志模块的处理速度更快时,将出现什么情况),以及服务生命周期(如何关闭日志模块,以及如何将服务状态通知生产者)。

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

性能测试:吞吐量、响应性、可伸缩性。

Thread.getState验证线程状态不可靠,被阻塞线程并不需要进入WAITING或TIMED_WAITING等状态,因此JVM可以选择通过自旋等待来实现阻塞。

这些测试应该放在多处理器的系统上运行,从而进一步测试更多形式的交替运行。然而,CPU的数量越多并不一定会使测试越高效。要最大程度的检测出一些对执行时序敏感的数据竞争,那么测试中的线程数量应该多于CPU数量,这样在任意时刻都会有一些线程在运行,而另一些被交换出去,从而可以检查线程间交替行为的可预测性。

在Java5.0中,ReentrantLock能提供更高的吞吐量,但在Java6中,二者的吞吐量非常接近。java6的内置锁算法和ReentrantLock中使用的算法相似。
竞争性能是可伸缩性的关键要素:如果有越多的资源被耗费在锁的管理和调度上,那么应用程序得到的资源就越少。锁的实现方式越好,将需要越少的系统调用和上下文切换,并且在共享内存总线上的内存通信量也越少,而一些耗时的操作将占用应用程序的计算资源。
降低链表中锁粒度的方法:为每个链表节点使用一个独立的锁,使不同的线程能独立的对链表的不同部分进行操作。每个节点的锁将保护链接指针以及在该节点中存储的数据,因此当遍历或修改链表时,我们必须持有该节点上的锁,知道获得了下一个节点的锁,只有这样,才能释放前一个节点上的锁。

在激烈的竞争情况下,非公平锁的性能高于公平锁的性能的一个原因是:在恢复一个被挂起的线程与该线程真正开始运行之间存在着严重的延迟。
当持有锁的时间相对较长,或者请求锁的平均时间间隔较长,“插队”带来的吞吐量提升则可能不会出现。
在实际情况中,统计上的公平性保证——确保被阻塞的线程能最终获得锁,通常已经够用了,并且实际开销也小很多。

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

读-写 锁允许多个读线程并发的访问被保护的对象,当访问以读取操作为主的数据结构时,它能提高程序的可伸缩性。
当锁的持有时间较长并且大部分操作都不会修改被守护的资源时,那么读-写锁能提高并发性。
ReadWriteLock的可选实现包括:
  • 释放优先  当一个写入操作释放写入锁时,并且队列中同时存在读线程和写线程,那么应该优先选择读线程,写线程,还是最先发出请求的线程?
  • 读线程插队 如果锁是由读线程持有,但有写线程正在等待,那么新到达的读线程能否立即获得访问权,还是应该在写线程后面等待?如果允许读线程插队到写线程之前,那么将提高并发性,但却可能造成写线程发送饥饿问题。
  • 重入性  读取锁和写入锁是否是可重入的?
  • 降级 如果一个线程持有写入锁,那么它能否在不释放该锁的情况下获得读取锁?这可能会使得写入锁被降级为读取锁,同时不允许其他写线程修改被保护的资源。
  • 升级  读取锁能否优先于其他正在等待的读线程和写线程而升级为一个写入锁?在大多数的读-写锁实现中并不支持升级,因为如果没有显式的升级操作,那么很容易造成死锁。
ReentrantReadWriteLock 为这两种锁都提供了可重入的加锁语义。在公平锁中,等待时间最长的线程将优先获得锁。如果这个锁由读线程持有,而另一个线程请求写入锁时,那么其他读线程都不能获得读取锁,直到写线程使用完并且释放了写入锁。在非公平锁中,线程获得访问许可的顺序时不确定的。写线程降级为读线程时可以的,但从读线程升级为写线程是不可以的。
CAS的主要缺点是,它将使调用者处理竞争问题(通过重试、回退、放弃),而在锁中能自动处理竞争问题(线程在获得锁之前将一直阻塞)。难以围绕着CAS正确地构建外部算法。
在大多数处理器上,在无竞争的锁获取和释放的“快速代码路径”上的开销,大约是CAS开销的两倍。
非竞争的CAS在多CPU系统中需要10到150个时钟周期的开销。
在中低程度的竞争下,原子变量能提供更高的可伸缩性,而在高强度的竞争下,锁能够更有效地避免竞争。
如果能够避免使用共享状态,那么开销将会更小。ThreadLocal
CAS的基本使用模式:在更新某个值时存在不确定性,以及在更新失败时重新尝试。
构建非阻塞算法的技巧:将执行原子修改的范围缩小到单个变量上。
非阻塞算法在设计和实现时非常困难,但通常能够提供更高的可伸缩性,并能更好地防止活跃性故障的发生。在JVM从一个版本升级到下一个版本的过程中,并发性能的主要提升都来至于对非阻塞算法的使用。

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

java程序只需通过正确的使用同步来找出何时将访问共享状态。

同步将限制编译器、运行时和硬件对内存操作重排序的方式,从而在实施冲排序时不会破坏JMM提供的可见性保证。在大多数主流的处理器架构中,内存模型都非常强大,使得读取volatile变量的性能与读取非volatile变量的性能大致相当。

如果缺少同步,那么将会有许多因素使得线程无法立即看到另一个线程的操作结果。在编译器中生成的指令顺序,可以与源代码中的顺序不同,此外编译器还会把变量保存在寄存器而不是内存中;处理器可以采用乱序或并行等方式来执行指令;缓存可能会改变将写入变量提交到主内存的次序;而且,保存在处理器本地缓存中的值,对于其他处理器是不可见的。这些因素都会使得一个线程无法看到变量的最新值,并且会导致其他线程中的内存操作似乎在乱序执行——如果没有使用正确的同步。
Java语言规范要求JVM在线程中维护一种类似串行的语义:只要程序的最终结果与在严格串行环境中执行的结果相同,那么上述所有操作都是允许的。
JVM依赖程序通过同步操作来协调多个线程访问共享数据。

守护线程不会阻碍JVM的关闭。
线程调度器是一个操作系统服务,它负责为 Runnable 状态的线程分配CPU时间。
上下文切换是存储和恢复CPU状态的过程,它使得线程执行能够从中断点恢复执行。上下文切换是多任务操作系统和多线程环境的基本特征。
使用Thread类的setDaemon(true)方法可以将线程设置为守护线程,需要注意的是,需要在调用start()方法前调用这个方法,否则会抛出IllegalThreadStateException异常。
线程转储是一个JVM活动线程的列表,它对于分析系统瓶颈和死锁非常有用。有很多方法可以获取线程转储——使用Profiler,Kill -3命令,jstack工具等等。
LinkedBlockingQueue的可伸缩性高于ArrayBlockingQueue。
AtomicStampedReference和AtomicMarkableReference 可以解决CAS的ABA问题。通过引入版本号。

 

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值