《Java并发编程实战》读书笔记

第一章-简介

多线程开销

  • 多线程临时挂起活跃线程转而运行另一个线程会频繁的出现上下文切换的操作,这将带来极大的开销:保存和恢复执行上下文,导致CPU时间将更多的花在线程调度上而不是线程运行上

第二章-线程安全性

线程安全问题的解决

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

线程安全的定义:正确性

  • 当多个线程访问某个类的时候,这个类始终能表现出正确的行为,那么这个类就是线程安全的。期间在主代码中不需要任何同步或者协调。

多线程操作必须具备原子性

  • 当一个线程执行“读取-修改-写入”的操作序列中,改操作必须具备原子性。
  • 典型的错误例子:单例模式中的懒汉式,因为操作不具备原子性对象可能被多次实例化。
  • Java中的锁机制也是确保原子性的内置机制。

Java内置锁synchronized

  • Java提供内置锁Synchronized的组成:锁对象引用,锁所保护的代码块。它确保它所保护的代码块以原子方式运行,中间也不可能有别的线程执行由同一把锁所保护的代码块。但这种机制同一时刻只能由一个线程来执行所以会导致性能问题。
  • synchronized可重入性原理:可重入锁的粒度为线程,当线程请求未持有锁的时候,jvm会记下锁持有者,并且将计数加1,同一个线程再次获取这个锁,计数递增。直到计数为0时释放。
  • synchronized可重入性可解决的问题:避免死锁。当持有锁的线程,去执行需要同一把锁的方法时就会死锁,因为持有锁的线程不会释放,去等待不可能获得锁的方法就会产生死锁。
  • 非静态的同步方法是锁定类的实例的也就是锁为当前对象,而静态的同步方法是锁定类的;

第三章-对象的共享

加锁与可见性

  • 加锁不仅仅是互斥行为,还包括内存可见性。无论读操作还是写操作都必须在同一把锁上同步,以确保所有线程都能看到共享变量的最新值

volatitle机制

  • 锁的机制既可以保证原子性又可以保证可见性,而volatitle只能保证可见性,通常用作某个操作的完成、终端或者状态的标志。

线程的封闭

  • 一种避免使用同步的方式就是不共享数据,只在线程内部访问称之为线程的封闭。例如JDBC中的Connection是非线程安全的只不过线程池是安全的,局部变量以及ThreadLocal类

ThreadLocal类

  • 使用ThreadLocal类来维持线程的封闭性是个常用的选择,JavaEE中事务的上下文信息保存在ThreadLlcal类中,程序很容易判断当前事务是哪一个事务类型。特定线程保存在ThreadLocal中的Thread对象中,当线程终止的时候会当作垃圾回收。

不可变性

  • 不可变对象一定是线程安全的。
  • 对象所有域都是final的,这个对象也是可变的,因为它可能保存一个可变对象的引用。
  • 满足不可变条件:状态不可修改、域为final类型、以及正确的构造过程

第四章-对象的组合

监视器模式

  • Java的监视器模式就是线程封闭原则的一种,synchronized就是监视器模式的经典使用。
  • 进入和退出同步代码块的字节指令为:monitorenter和monitorexit,Java内置锁称为监视器锁或者监视器。
  • 遵循Java监视器模式,会把对象所有可变状态封装起来,由对象自己内置锁来保护。

线程委托

  • 委托是创建线程安全的一个最有效策略,即将安全性委托给现有的线程安全类,让现有的线程安全类管理所有的状态即可。

第五章-基础构建模块

同步容器类的问题以及解决

产生问题:

  • 迭代期间,可能对元素增删操作从而造成ArrayIndexOutOfBoundsException

解决方案:

  • 迭代期间加锁。不推荐因为会极大降低吞吐量以及CPU利用率
  • 将计数器的变化与容器关联起来、如果在迭代期间计数器被修改那么将会抛出ConcurrentModificationException错误,例如HashMap
  • 拷贝出一个副本,在副本上迭代,但是拷贝过程中是需要对容器加锁的。例如CopyOnWriteArrayList
  • 并发容器代替例如Vector、Collections.synchronizedList类似的同步容器将是一个不错的选择,例如ConcurrentHashMap

并发容器

  • 并发容器用来替代同步容器,极大的提高了伸缩性并降低风险。例如:ConcurrentLinkedqueue,ConcurrentHashMap。
  • 虽然Queue是LinedList来实现的,但是LinkedList仍然需要一个Queue类,因为它能去掉List的随机访问需求,从而实现高效并发。
  • BlockingQueue扩展了Queue,增加可阻塞插入和获取的操作,而Queue操作不会阻塞,如果队列为空将返回空值。
  • ConcurrentHashMap返回的迭代器具有弱一致性,可以容忍并发修改,创建迭代器的时会遍历已有元素,但是对于整个map计算的方法例如size与isEmpty仍然只是个近似值,弱化了这些操作来提升get、put、remove之类的操作
  • CopyOnWriteArrayList代替同步list,修改操作都会发布一个新的容器副本,同时会保留一个指向底仓基础数组的引用,它不会被修改,位于迭代器的起始位置。
  • Executor是一个典型的生产者-消费者模式,基于BlockingQueue实现,极大的提高了并发性。

双端队列

  • BlockingDeque通过扩展Queue与BlockingQueue,它是一种双端队列可以在队列头与队列尾高效的插入语移除。
  • 双端队列工作窃取,完成自己任务的时候可以从别的消费者双端队列的末尾秘密获取工作,也适用于即是消费者也是生产者、例如:一个工作线程找到新的任务时就将它放入自己队列的末尾。

同步工具类

同步工具类都封装一些状态,用来决定执行线程是继续等待还是执行,例如Semaphore、Barrier、CountDownLatch(闭锁)

  • CountDownLatch、闭锁初始化一个为正数的计数器,代表需要等待的事件数量,countDown用来递减计数器,await会一直等待直到这个计数器为零,表示没有需要等待的线程。
  • FutureTask也可以作为一种闭锁、实现了Future语义,表示计算通过Callable来实现,通过Future.get获取任务结果,如果任务完成get立即返回否者阻塞,它是实现多线程其中的一种方式。
  • Semaphore(信号量)、可以控制某特定资源的操作数量或同时执行某个指定操作数量,acquire表示获取一个许可,release表示释放许可。如果初始值唯一的Semaphore可以作为一个不可重入的互斥锁。同时Semaphore可以将任何一种容器变为有界阻塞容器,添加成功获取许可,失败或者移除释放许可。(在此可以考虑下BlockingQueue实现原理)
  • CyclicBarrier(栅栏),也类似于闭锁,区别在于闭锁用于等待事件,栅栏于等待其他线程。闭锁做的减法,栅栏做的加法。栅栏每个线程执行完需要等待,直到所有线程都到齐,闭锁代表的是等待数量,直到这个数量递减为零。

第六章-任务执行

为什么要用多线程来执行任务

  • 单线程的弊端:单线程串行的执行任务,在I/O操作完成之前CPU将处于空闲状态,无法提供高吞吐率以及快速响应性。
  • 过多创建线程的弊端:线程生命周期开销高,线程的创建销毁以及上下文切换将是一种开销,活跃的线程会消耗内存资源,如果可运行的线程多余可处理的线程数量,那么这些线程将会闲职浪费内存资源并且给jvm回收造成压力。线程的数量受制于jvm启动参数,Thread构造函数请求栈的大小以及操作系统限制,超出这些限制将抛出OutOfMemoryError异常。

线程池Executor

  • 线程池提供有界的线程数量,通过重用线程而不是创建线程避免线程创建与销毁产生的开销,适当的线程池大小可以是处理器保持忙碌起来同时可以避免过多的线程竞争资源使得程序耗尽内存或失败
  • Executors中的静态工厂方法提供了创建线程池灵活的方式
  • Executor通过扩展了ExecutorService接口添加了管理Executor生命周期的方法,ExecutorService生命周期状态:运行、关闭、终止,其中shutdown等待线程执行完平缓关闭,shutdownNow则尝试取消所有运行中的任务并且不再启动队列中尚未开始的任务来粗暴的关闭。
  • timerTask的弊端、Timer每次只会创建一个线程如果任务执行时间超过设定的频率将导致任务结束快速的多次调用或者调用丢失,并且timerTask抛出异常Timer不会捕获,并且抛出异常终止定时线程也不是恢复线程。因此需要ScheduledThreadExecutor与newScheduledThreadPool工厂来解决这个问题

携带结果的任务Callable与Future

  • Callable可以返回值并且抛出异常,Future提供相应的方法判断是否已经完成或取消,以及获取任务的结果和取消任务。执行get的时候如果任务已经完成就会立即返回或者抛出一个Exception,否者阻塞。总的来说Callable来执行具体任务,返回的Future用来描述人物的执行情况。
  • 实现了Callable的接口类既可以交给FutureTask来执行也可以交给线程池来执行。FutureTask实现了Runnable,因此也可以将它提交给Executor来执行,都将返回一个Future
  • 为解决Callable结合ExecutorService只能有一个返回,虽然可以实现异步,但是get部分执行的太快无异于串行执行,并发有限。CompletionService结合了Executor与BlockingQueue,多个CompletionService可以共享一个Executor,将执行的结果放入BlockingQueQue中,消费者可以根据放入的数量进而去take相对数量的结果。
  • ExecutorService可以利用invokeAll一次性放入一组实现了Callable接口的方法,例如:executorService.invokeAll(tasks); List tasks ; QuoteTask implements Callable。

第七章-取消与关闭

任务取消与中断

  • Thread分别提供interrupt、isInterrupted、interrupted来表示中断任务、判断是否中断、清除当前线程中断状态。interrupted不会立即停止正在执行的线程,只是传递中断的消息,平滑中断。发出中断请求时wait、sleep与join等将严格执行中断请求抛出中断异常。
  • 多线程环境中断异常InterruptedException处理,通过future来取消将是个不错的选择,当Future.get抛出InterruptedException时,如果不需要知道结果,就可以调用Future.cancel来取消任务
  • 对于Socket I/O类似的因为内置锁而阻塞,中断请求只能设置线程中断状态,除此并无他用。如果一个线程因为等待某个内置锁而阻塞,那么无法响应中断。处理这种问题可以中邪Thread的interrupt方法,使其先关闭io再中断线程。如果使用的是Future、Callable来实现的线程可以重写cancel方法先关闭io再执行线程中断操作,具体参考7.1.7newTaskFor工厂方法封装

停止基于线程的服务

  • 对于持有线程的服务,应该提供生命周期的方法、start,stop
  • 通过synchronized与状态位的方式给线程增加管理生命周期。详见7.2.1
  • 利用ExecutorService自带管理线程生命周期的方式来,管理线程将是个不错的选择。建议调用shutdown下面调用awaitTerminaltion方法,他将等待线程执行完之后关闭。详见7.2.2
  • 在生产者消费者数量已知的情况下,可以利用队列,在队列里面加线程停止的标志用来管理停止线程。这样消费者可以将工作处理完之后再停止,比较适合无界的队列。详见7.2.3“毒丸”对象

弥补shutdownNow的局限性

  • shutdownNow只能返回已提交但尚未开始的任务,但不能返回已经开始但尚未结束的任务,可能导致丢失那些已经开始但未结束的任务。
  • 通过重写ExecutorService的execute方法,将已经开始但尚未结束的任务放到集合里面,配合shutdownNow使用,来弥补shutdownNow局限性。详见7.2.5

处理非正常线程终止(线程异常处理)

  • 如果使用try-finally接收异常通知缺没有异常处理,那么任务会悄悄失败,从而造成极大混乱
  • 如果未捕获异常而退出,jvm会把这个事件报告给UncaughtExceptionHandler异常处理器,默认行为是输出栈追踪信息到System.err
  • 处理线程的异常,通常可以实现UncaughtExceptionHandler接口重写里面的uncaughtExceptoin方法。通常打印log日志,或者尝试重启线程,或者关闭程序等。
  • 如果线程池需要设置一个UncaughtExceptionHandler那么需要为Executor提供一个ThreadFactory。值得注意的是Executor只有通过execute提交的任务才能把它抛出的异常交给UncaughtExceptionHandler未捕获异常处理器,而通过submit提交的任务,无论是未检查异常还是已检查异常都将是任务返回的一部分,如果submit发生了异常将会被Future.get封装在executionException中重新抛出。

jvm关闭线程处理方案

  • 可以通过Runtime.addShutdownHook注册"钩子"做一些最终的清理工作,jvm关闭时会等所有钩子都执行结束时候才会停止。如果注册的钩子是非线程安全的将影响jvm关闭时间
  • 可以通过守护线程来做一些辅助性工作,但是它不能替代普通线程的生命周期,因为jvm关闭时所有守护线程将会被直接抛弃

第八章-线程池使用

线程饥饿死锁与运行较长任务处理

  • 线程池单线程中如果一个任务将另一个任务提交到同一个Executor中,并且等待这个被提交任务的结果那么可能引发死锁。
  • 如果任务数量大于线程池数量,那么新的任务需要等待其他任务释放
  • 对于运行时间较长的任务,可以设定限时等待,例如:BlockingQueue.put 、 CountDownLatch.await 、 Selector.select

线程池大小如何设置

  • 设置线程池首先要判断是计算密集型还是I/O密集型。
  • 计算密集型通常大小为:CPU数量+1,额外的线程确保有的线程意外暂停,保证CPU时钟周期不被浪费。
  • I/O密集型:需要估算出任务等待时间与计算时间的比值,通常CPU数量2倍大小
  • 计算线程数量公式: 线程数量=CPU数量*(1+等待时间/计算时间)
  • 理想情况下,计算每个任务对资源的需求量,用该资源可用的总量除以每个任务的需求量就是线程池大小的上限

线程创建与销毁

  • 线程池只有工作队列满的情况下才会创建超过线程池基本大小的线程但不会超过最大大小,如果线程空闲时间超过存活时间那么此线程可被标记可回收,如果是超过线程池基本大小的线程这个线程将被销毁。

SychronousQueue特殊队列

  • 如果使用无界的线程池那么使用newCachedThreaPool将是个不错的选择,内部使用SychronousQueue实现,并且默认初始化线程池基本大小为0,SychronousQueue并不是真正意义的队列,如果没有线程等待消费,那么将智能创建新的线程,并且默认60s将空闲的线程回收。它是将任务直接交给工作线程,提高了性能。相当于提供了一个智能创建一个可缓存的线程池,空闲有效期为60s

线程池饱和策略

  • 提供四种线程池饱和策略:饱和时新添加任务抛异常、饱和时抛弃最新提交的任务、饱和时抛弃最旧的任务、饱和时提交的新任务将由调用线程池的调用者来执行,如果是main执行的execute那么mian线程将执行最新的任务。
  • 如果采用调用者执行策略那么可以使用Semaphore(信号量)来控制新提交任务的速率,详见8.3.3。如果不加以控制那么服务器将过载不会继续accept,到达的请求将在TCP层队列中,如果继续过载那么将抛弃新的请求。

线程池的线程工厂

  • 我们在使用线程池的时候通常需要我们创建一个线程工厂,例如:ThreadPoolExecutor(int corePoolSize, int maximumPoolSize, long keepAliveTime, TimeUnit unit,BlockingQueue workQueue, ThreadFactory threadFactory),自定义一个threadFactory将是一个不错的选择,例如:我们可以将线程池的线程指定一个UncaughtExceptionHandler,用来搜集日志,或者将线程基于线程池构造线程的名字用来区分线程属于哪个线程池等等。8.3.4代码中为我们构造了一个可以在UncaughtExecptionHanler中打印异常信息以及统计线程创建销毁情况的线程工厂。

扩展ThreadPoolExecutor

  • ThreadPoolExecutor是可扩展的,我们可以在子类中改写beforeExecute,afterExecute、terminated方法,正常运行返回还是抛出一个异常返回beforeExecute在execute之前执行,afterExecute在execute之后执行,线程关闭操作时调用terminated,用来释放在生命周期分配的各种资源。当然beforeExecute抛出一个RuntimeException的话,afterExecute将不会被调用。

第九章-GUI

因为gui几乎没人用所以不看了

第十章-避免活跃性危险

死锁发生的背景与原因

死锁背景:线程A持有锁L并想获取锁M,同时,线程B持有锁M并尝试获取锁L,那么这俩线程将永远等下去;数据库系统中检测到死锁,会选择一个牺牲者并放弃这个事务,牺牲者从而释放它所持有的资源,从而解决死锁,JVM发生死锁恢复的唯一方式就是终止并重启。

  • 顺序死锁,两个线程以不同的顺序获取相同的锁、如果以固定顺序获取锁则不会出现顺序死锁问题。
  • 动态顺序死锁,动态赋予锁的顺序不当引发的动态顺序死锁,可以通过System.identityHashCode计算锁的散列值来定义锁的顺序。详见10.1.2
  • 在协作对象发生死锁,协作的两个对象互相持有并等待对方的锁。可以通过公开调用来避免互相协作对象之间的死锁
  • 资源死锁,例如两个不同的数据库连接池,请求资源时不遵循相同的顺序而导致互相等待。
  • 另一种资源死锁就是线程的饥饿死锁,在单线程环境中提交的任务等待子任务的结果,导致一直等待。

死锁的诊断与避免

  • 确保获取锁的顺序一致,顺序死锁是最常见的活跃性故障
  • 使用定时锁,设置超时时间。比如显示锁,Lock类中的tryLock
  • JVM可以在等待关系图中通过搜索循环来找出死锁,然后生成线程转储,线程转储包含加锁信息,线程持有哪些锁,以及被阻塞的线程正在等待获取哪一个锁
  • 活锁定义、线程总是不断重复执行相同的操作,总会执行失败,通常发生在事务消息中,如果不能成功处理某个消息,消息机制将回滚整个事务从重新放到队列开头,不断重复执行。

第十一章-活跃性、性能与测试

性能与伸缩性概念、以及性能权衡

  • 提升性能意味以更少的资源做更多的事情,但是多线程总会引入额外的开销:线程间协调(加锁,内存同步),上下文切换,线程创建销毁,线程调度
  • 可伸缩性指的是、当增加资源时吞吐量与处理能力响应增加。性能有两个指标:任务单元处理速度与整个程序的生成量与吞吐量,有时候这俩指标相互矛盾。例如:三层架构与单体应用,三层架构可伸缩性高但是单个任务拆为更多的任务处理更慢,但是单一系统虽然避免了网络延时等开销,但是难以伸缩性。
  • 快速排序适合大数据集,冒泡排序适合小数据集。所以用那种算法需要综合考虑,资源、安全性、衡量优化的指标:计算时间,安全性、可维护性等

Amdahl定律

(Amdahl读音:阿姆达尔)—公式见11.2

  • 多线程中的串行部分是影响吞吐量的一个重要的因素,比如多线程从队列中获取任务这就是其中一个串行部分,或者将执行后的任务保存到日志或者某种数据结构这也是一个串行部分。
  • 并发非阻塞队列对于传统阻塞队列的优势更为明显,比如:ConcurrentLinkedQueue更优于synchronizedList

线程引入的开销

  • 上下文切换、保存当前运行线程的执行上下文并且将新调度进来的线程执行上下文设置为当前上下文;
  • JVM消耗时钟CPU周期越多应用程序可用CPU周期越少;因为新线程被切换进来的时候,所需要的数据不在当前处理器本地缓存中,首次运行更加缓慢,所以为了提高吞吐量设置线程最小执行时间;太多的线程阻塞同样换增加上下文切换的开销;
  • 内存同步、内存同步开销主要在于有竞争的同步的开销,在synchronized与volatile中会通过一些特殊的指令,即内存栅栏,刷新缓存,但是它抑制一些编译器优化操作,在内存栅栏中操作不能被重排序;同步内存可见性,会增加共享内存总线的通信量,因为带宽是有限的所以所有使用同步的线程都会受影响
  • 线程阻塞、JVM处理阻塞时有两种办法:自旋等待、挂起被阻塞线程。如果阻塞较短适合自旋方式否者适合挂起;线程挂起,被阻塞时时间片未用完将被交换出去,而获取锁的时候又被切换过来导致会产生两次额外的上下文切换;

减少锁的竞争

  • 串行锁弊端:降低可伸缩性、上下文切换降低性能,因此减少锁竞争可以提高性能与可伸缩性。发生锁竞争可能性:请求频率与持有锁时间
  • 缩小锁的范围、核心还是降低锁持有时间并且符合Amdahl定律,因为串行部分减少了。但是当一个同步代码块分解为多个同步代码块时候,JVM会执行锁力度粗化操作,将分解的锁重新合并起来反而会对性能产生负面影响。
  • 减小锁的粒度、如果一个锁维护很多相互独立的状态,那么可以将这个锁分解为多个锁。核心是降低锁的请求频率,将锁变为非竞争锁。
  • 锁分段、锁分段是锁分解的内容的进一步的分解,减少锁竞争;但是锁分段的劣势在于,某些情况下需要获取所有的锁来加锁整个容器,比如:ConcurrenthashMap扩容的时候,需要重新计算键值的散列值分配到更大的桶集合中,这就需要获取所有锁
  • 避免热点域、ConcurrentHashMap通过为每个分段维护一个独立的计数器,如果调用size方法与修改map操作频率相当,那么将size缓存到一个volatile中。将独占锁维护的计数器进行分解来避免热点域

本章小结:多线程侧重点是增加吞吐量和可伸缩性;Amdahl定律得知,程序可伸缩性取决于代码中被串行的比例;程序中的串行操作主要是因为独占锁,可通过减少锁持有时间降低锁力度以及用非独占锁来提升可伸缩性;

第十二章-并发程序的测试

正确性测试

  • 基于Semaphore信号量可实现有界阻塞队列,但是大多数还是建议使用ArrayBlockingQueue或者LinkedBlockingQueue,详见12.1
  • 测试阻塞操作、若线程等待一段时间后被程序主动中断可为测试成功,通过Thread.getState来验证线程是否在某个条件等待阻塞是不可靠的因为它可以通过自旋来实现阻塞。

性能测试

  • LinkedBlockingQueue的可伸缩性高于ArrayBlockingQueue,因为LinkedBlockingQueue优化后的链接队列算法将队列头结点更新操作与位节点更新操作分离开来,而且节点对象的建立是基于线程本地的可以降低竞争程度
  • 缓存过小会导致过多上下文切换,从而降低吞吐量,因此除非线程是密集的同步需求被持续阻塞,否者非公平锁能实现更好的吞吐量

性能测试陷阱

  • 垃圾回收的影响
  • 动态编译、方法运行次数足够多那么动态编译器会将它编译为机器代码,解释执行同样会对测试带来影响
  • 同一个方法在不同的程序中编译的代码不同。动态编译器对单线程的优化,即使测试单线程也应该与多线程性能测试结合在一起那么这些优化就不复存在
  • -server模式下会对无用的代码进行消除优化,所以每个计算结果都应该被使用并且还是不可预测的,否则一个智能动态优化编译器会用预先计算结果来代替计算过程,从而对测试带来影响
    并发常见问题:不一致同步,未释放锁,双重检查加锁,休眠或者等待中持有锁,用Lock作为同步代码块,自旋循环

第13章-显示锁

Lock与ReentrantLock

  • 使用显示锁原因、内置锁无法中断正在获取锁的线程,内置锁必须在获取该锁的代码中释放
  • 显示锁lock可以实现轮询锁,定时锁
  • 显示锁Lock中的lockInterruptibly方法能够在获取锁的同时保持对中断的响应
  • 显示锁可以在非块结构加锁

性能因素考虑

  • 性能是可伸缩性的关键指标、锁实现方式越好那么系统调用与上下文切换越少并且在共享内存总线上的同步信息量越少,Java6以后内置锁算法与ReentrantLock算法类似所以性能上也相差无几

公平性

ReentrantLock默认非公平锁,内置锁也不能保证公平性

  • 公平锁中如果另一个线程持有锁或者有其他线程等待这个锁,那么新发出的请求线程将放入队列,而在非公平锁中只有锁被某个线程持的时候,有新发出请求线程才会被放入队列
  • 公平锁在挂起线程与恢复线程存在开销,有些需要依赖公平排队的算法来确保他们的公平性,因此非公平锁通常性能高于公平锁
  • 竞争激烈的情况下非公平锁性能较高,因为一个线程在完全唤醒之前可能它所获取的锁在这个间隙被另一个线程获得使用释放掉了;而持有锁时间较长或者请求锁的时间较长的情况下那么适合使用非公平锁,因为"插队"带来吞吐量的提升不可能出现

synchronized与ReentrantLock之间选择

优先选择内置锁synchronized,内置锁无法满足的情况下选择ReentrantLock

  • 需要一些高级功能:可定时、可轮询与可中断锁获取,公平队列以及非块结构的锁,才使用ReentrantLock
  • java6之后ReentrantLock可以通过注册一个管理和调试的接口,从而与ReentrantLocks相关的加锁信息出现在线程转储中
  • 未来可能会提升synchronized,他是JVM内置属性,能执行一些优化,比如:线程封闭锁对象的消除优化,增加锁粒度消除内置锁同步等

第14章-构建自定义同步工具

自定义同步工具常见方案

  • 将失败信息传递给调用者,然后循环重试
  • 轮询加休眠的方式实现阻塞
  • 使用wait与notify、notifyAll实现条件队列,相比轮询的方式优化了CPU效率、比睡眠的方式提高了响应性、不需要轮询获取锁减少了上下文切换

使用条件队列

  • 条件谓词、调用某个特定条件谓词的wait时,调用者必须持有队列相关的锁,并且这个锁保护着构成条件谓词的状态变量
  • 只有等待线程的类型都相同才可以使用单一的notify否则建议使用notifyAll
  • 为防止notify虚假唤醒必须每次再次测试条件谓词、虚假唤醒场景:当线程A由wait重新获得锁并请求资源的时候,线程B在这个过程中把这个资源的可用情况变成了假。

Condition对象

  • 同一把锁多个条件谓词的情况,Lock中的Condition对象将是个不错的选择,一个lock可以有多个Condition对象

AbstractQueuedSynchronizer(AQS)

  • ReentrantLock、Semaphore、CountDownLatch、ReentrantReadWriteLock等都是基于AQS构建的,AQS解决大量细节问题:等待线程采用FIFO操作顺序,基于AQS的同步器中还可以定义一些标准来判断某个线程是通过还是等待,AQS充分考虑可伸缩性;
  • AQS维护了一个整数状态的信息、例如:ReentrantLock可以用它来表示重复获取锁的次数,Semaphore用来表示剩余许可数量等。可以用来实现独占或者非独占工作,这由同步器的语义来决定,如果需要独占获取需要实现一些保护方法:tryAcquire、tryRelease,如果需要支持共享则应该实现tryAcquireShared、tryReleaseShared。AQS中的acquire、acquireShared、release与releaseShared等方法都会调用子类中带有前缀try的版本来判断某个操作是否执行。

juc同步器类中的AQS的使用

  • ReentrantLock、维护了一个owner变量来保存所有者的标示,只有刚刚获取到锁或者正要释放锁的时候才会修改这个变量。如果同一线程多次获取锁那么就将state变量值加1,这就是可重入锁
  • Semaphore使用AQS的tryAcquireShared减少数值表示获得许可,通过TryReleaseShared增加数值用来表示释放许可;CountDownLatch用法与AQS相似,countDown调用release从而导致计数值递减,await调用acquire,当计数器为0的时,acquire立即返回否者阻塞。
  • 在FutureTask中、Future.get语义类似于闭锁的语义:事件发生之前所有线程将在队列中等待,AQS的state状态用来表示任务的状态:正在运行、已完成、已取消。同时有额外的状态变量用来存储计算结果或者异常,此外还维护了一个引用来响应中断;
  • ReentrantReadWriiteLock、利用AQS使用一个16位的状态表示写入锁的计数,另一个16位的状态表示读取锁的计数,读取锁操作共享获取与释放方法,写入锁操作使用独占获取与释放方法;AQS维护了一个等待队列,记录是共享访问还是独占访问,如果是公平锁则维护一个FIFO的队列,如果是非公平锁则维护两个队列。
    小结:锁的等待队列包括:可中断不可中断条件等待、公平或者非公平队列操作、以及超时限制的等待。

第15章-原子变量与非阻塞同步机制

锁的劣势

  • 上下文切换;
  • 当一个线程等待锁时他不能做任何事情;
  • 如果一个线程持有锁被延时执行,那么所需要这个锁的线程都无法执行下去;
  • 如果持有锁的线程被永久阻塞那么将出现死锁现象;

硬件对并发的支持

  • 处理器提供一种特殊的指令、提供某种形式的原子读-改-写指令
  • 比较并交换compareAndSwap、无论替换是否成功都将返回内存原有的值。
  • CAS性能远大于锁的性能、因为锁及时在最好的情况下至少需要一个CAS操作以及其它锁相关的操作。CAS唯一的缺点就是需要使用者自行处理竞争问题(重试、回退、放弃),而锁能自动处理竞争问题。

原子变量

  • 原子变量属于更高级的volatile既提供原子性又保证可见性、常用原子变量:AtomicInteger、AtomicLong、AtomicBoolena、AtomicReference
  • 在高度竞争的情况下锁的性能可能超过原子变量的性能,因为锁发生竞争会挂起线程从而降低CPU使用率以及共享内存总线上的同步通信量,但这是理想的情况,实际很少会有这种竞争程度。

非阻塞算法

  • 基于锁算法会发生阻塞风险、而基于CAS算法是一种无阻塞算法又称为无锁算法,非阻塞算法中不会出现死锁以及优先级反转问题但是可能会出现饥饿和活锁问题,因为会反复的重试
  • 更新单个变量是简单的比如:计数器和栈。但是需要更新两个变量,比如带有头指针与尾指针的链表,新插入元素需要同时维护尾节点next指针与尾指针,可以根据队列的中间状态与稳定状态来判断是否需要推进尾节点与继续插入新节点,类似于达到最终一致性;
  • ABA问题,原子变量从A改到B,又从B改到A,仍然是发生了变化,可以通过从更新一个值的方式改为更新两个值,包括一个引用一个版本号,例如:atomicStampedReference类,数据库乐观锁也是类似的做法不过是利用数据库的行锁。

第16章-Java内存模型

java内存模型

  • JMM规定了变量的写入在何时将对其它线程可见。如果没有线程同步那么将会有很多因素使得线程无法理解甚至永远看到另一个线程的操作结果,例如:编译器生成的指令顺序与源代码中不同;编译器把变量保存在寄存器中而不是内存中;处理器可以采用乱序或者并行方式来执行指令;缓存可能改变写入变量提交到主内存中的次序;保存处理器本地缓存值对其它处理器不可见
  • 平台内存模型、在共享内存的多处理器架构中,每个处理器有自己的缓存,定期与主内存进行协调。在多处理器架构中允许处理器在任意时刻同一位置看到不同的值,要想知道其它处理器任意时刻进行的工作需要较大的同步开销。当需要共享数据时,可以通过一些特殊的指令(内存栅栏或内存屏障),来实现额外的存储协调保证;
  • 重排序、在多线程中如果每个线程各个操作不存在数据依赖性,那么操作可以乱序执行
  • Happens-Before操作发生的顺序、如果两个操作直接缺乏Happens-Before关系,那么JVM可以对他们重排序。Happens-Before包括:程序顺序规则、显示锁/监视器锁规则、线程启动顺序规则、传递性规则等

发布

  • 不安全发布、若无法确保共享引用的操作在另一个线程加载之前执行,那么新对象写入操作将于对象中各个域写入操作重排序。初不可变对象以外,使用另外线程初始化的对象通常不安全,除非是在该对象发布之后执行。
  • 安全的发布、安全的发布模式可以确保对象对其它线程可见的,因为它保证发布对象的操作在其它线程使用该对象引用之前执行,但是对于其它对象的可见性无法保证,而Happens-Before保证了更强的可见性。而Happens-Before排序是在内存访问级别操作的,安全发布更接近程序设计。
  • 安全初始化模式、静态初始化器在JVM类初始化阶段执行,即在类加载后且线程使用之前,JVM初始化期间获得一个锁,并且每个线程都至少获取一次这个锁保证类已被加载,所以静态初始化期间内存写入操作自动对所有线程可见,仅适用于构造时状态。例:16-6通过推迟ResourceHolder初始化操作,直到使用这个类才初始化所需对象,这是一个较好的单例模式实现。
  • 双重检查加锁DCL、虽然初始化过程使用了同步但是读取没有同步、同步不能禁止指令重排、因为指令重排的问题resource=new Resource();分为三步:1开辟内存空间地址、2初始化对象、3指针指向这个地址,这三步顺序可能会导致对象未初始化完成指针就指向了这个地址,因为这三步并没有依赖性的关系。这时可以使用volatile防止指令重排。

初始化安全性

  • 初始化安全性只能保证final域可达的值从构造完成时开始的可见性,如果后面对这个final域有所修改那么就不能保证
    小结:java内存模型说明了线程对内存的操作在哪些情况下对于其它线程可见。包括:Happends-Before偏序关系排序,这种关系基于内存操作和同步操作级别来定义的。
  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值