第7章 取消与关闭
很重要,因为我看不懂(我好菜啊)
第8章 线程池的使用
在一些任务中,需要拥有或排除某种特定的执行策略。如果某些任务依赖其他的任务,那么会要求线程池足够大,从而确保它们依赖的任务不会被放入等待队列中或被拒绝,而采用线程封闭机制的任务需要串行执行。通过将这些需求写入文档,将来的代码维护人员就不会由于使用了某种不合适的执行策略而破坏安全性或活跃性。
每当提交了一个由依赖性的Executor任务时,要清楚的知道可能会出现线程“饥饿”死锁,因此需要在代码或配置Executor的配置中心记录线程池的大小限制或配置限制。
第10章 避免活跃性危险
在安全性与活跃性之间通常存在着某种制衡。我们使用加锁机制来确保线程安全,但是如果过度的使用加锁,则可能导致顺序死锁。同样,我们使用线程池和信号量来限制资源的使用,但这些限制的行为可能会导致资源死锁。
10.1死锁
线程A等待线程B所占有的资源,而线程B等待线程A所占有的资源,如果在图中形成一个环路,那么就存在一个死锁。
10.1.1锁顺序死锁
如下图所示,一个线程拥有left锁,去尝试right锁,而一个线程拥有right锁,去尝试left锁,就产生死锁。
class LeftRighrDeadLock { private final Object left = new Object(); private final Object right = new Object(); public void leftRight() { synchronized (left) { synchronized (right) { System.out.println(); } } } public void rightLeft() { synchronized (right) { synchronized (left) { System.out.println(); } } } }
10.1.2动态的锁顺序死锁
有的时候我们并不清楚是否在锁顺序上有足够的控制权来避免死锁的发生。正如转账所示,所有的线程都似乎安装相同的顺序来获得锁,但是事实上锁的顺序取决于参数顺序,如下面的代码所示。
transferMoney(myAccount,yourAccount,10); transferMoney(myourAccount,myAccount,,20);
要解决上面的问题,必须定义锁顺序,该方法将返回由Object.hashcode返回的值定义锁的顺序。虽然增加了额一些代码,但是消除了发生死锁的可能性。如果Account中包含一个唯一的,不可变的,并且具备可比性的键值,那就不需要例如下面代码中“加时赛”锁作用的lock。
private final Object lock = new Object(); public void transferMoney(Object fromAcct, Object toAcct, int num) { int fromHash = fromAcct.hashCode(); int toHash = toAcct.hashCode(); if (fromHash < toHash) { synchronized (fromAcct) { synchronized (toAcct) { System.out.println("do something..."); } } } else if (toHash < fromHash) { synchronized (toHash) { synchronized (fromHash) { System.out.println("do something..."); } } } else { synchronized (lock) { synchronized (toHash) { synchronized (fromHash) { System.out.println("do something..."); } } } } }
10.1.3 在协作对象直接发生的死锁
如下面代码所示,线程A调用car.carMethod方法时,拥有自己锁并且尝试carList的锁。如果此时线程B调用carList.carListMethod方法时,拥有自己的锁,并且尝试car的锁时,就发生了死锁。
class Car { private String name; private CarList carList; public synchronized void carMethod(String name) { this.name = name; carList.carListMethod(this); } } class CarList { private List list = new ArrayList(); public synchronized void carListMethod(Car car) { boolean contains = list.contains(car); if (!contains) { car.carMethod(); } } }
10.1.4 开放调用
如果在调用某个方法时不需要持有锁,那么这种调用方法就是开放调用。
//开放调用的反例 public synchronized void method(){ otherClassInstence.synchronizedMethod(); } //开放调用 public void method(){ synchronized (this){ doSomeThing(); } otherClassInstence.synchronizedMethod(); }
有的时候会丢失原子性。
在程序中应尽量使用开放调用。与那些在持有锁的时候调用外部方法的程序相比,更容易对依赖开放调用的程序进行死锁分析。
10.2死锁的避免与诊断
10.2.1 支持定时的锁
Lock lock = new ReentrantLock(); try { lock.tryLock(10, TimeUnit.SECONDS); //logic } catch (Exception e) { e.printStackTrace(); } finally { lock.unlock(); }
10.2.2通过线程转储信息来分析死锁
10.3其他活跃性危险
10.3.1饥饿
要避免使用线程优先级,因为这会增加平台依赖性,并可能导致活跃性问题。在大多数并发应用程序中,都可以使用默认的线程优先级。
10.3.3活锁
活锁(Livelock)是另一种形式的活跃性问题,该问题尽管不会阻塞线程,但也不能继续执行,因为线程将不断重复执行相同的操作,而且,总会失败。 活锁通常发生在处理事务消息的应用程序中:如果不能成功地处理某个消息,那么消息处理机制将回滚整个事务,并将它重新放到队列的开头。如果消息处理器在处理某种特定类型的消息时存在错误并导致它失败,那么每当这个消息从队列中取出并传递到存在错误的处理器时,都会发生事务回滚。由于这条消息又被放回到队列开头,因此处理器将被反复调用,并返回相同的结果。(有时候也被称为毒药消息,Poison Message. )虽然处理消息的线程并没有阻塞,但也无法继续执行下去。这种形式的活锁通常是由过度的错误恢复代码造成的,因为它错误地将不可修复的错误作为可修复的错误。
第11章 性能与可伸缩性
11.1 对性能的思考
要想通过并发来获得更好的性能,需要努力做好两件事情:更有效地利用现有的处理资源,以及在出现新的处理资源时使程序尽可能地利用这些新资源。
11.1.1 性能与可伸缩性
应用程序的性能可以采用多个指标来衡量,例如服务时间、延迟时间、吞吐率、效率、可伸缩性以及容量等。其中一些指标(服务时间、等待时间)用于衡量程序的“运行速度”,即某个指定的任务单元需要“多快”才能处理完成。另一些指标(生产量、吞吐量)用于程序的“处理能力”,即在计算资源一 定的情况下,能完成“多少”工作。
可伸缩性指的是:当增加计算资源时(例如CPU、内存、存储容量或1/O带宽),程序的吞吐量或者处理能力能相应地增加。
11.1.2 评估各种性能权衡因素
避免不成熟的优化。首先使程序正确,然后在提高运行速度-----如果它还运行的不够快。
以测试为基准,不要猜测。
11.2 Amdahl定律
Amdahl定律:在增加计算资源的情况下,程序理论上能够实现最高加速比,取决于程序中可并行组件与串行组件所占的比重。
如下面代码所示,串行化的部分是queue.take()
class WorkerThread extends Thread { private final BlockingQueue<Runnable> queue; public WorkerThread(BlockingQueue<Runnable> queue) { this.queue = queue; } @Override public void run() { while (true) { try { Runnable task = queue.take(); task.run(); } catch (Exception e) { break; } } } }
在所有的并发程序中都包含一些串行化部分。如果你认为在你的程序中不存在串行部分,那么可以再仔细的检查一遍。
11.3 线程引入的开销
对于为了提升性能而引入的线程来说,并行带来的性能提升必须超过并发导致的开销。
11.3.1 上下文切换
切换上下文需要一定的开销,而在线程调度过程中需要访问由操作系统和JVM共享的数据结构。应用程序、操作系统以及JVM都使用一组相同的CPU。在JVM和操作系统的代码中消耗越多的CPU时钟周期,应用程序的可用CPU时钟周期就越少。但上下文切换的开销并不只是包含JVM和操作系统的开销。当一个新的线程被切换进来时,它所需要的数据可能不在当前处理器的本地缓存中,因此上下文切换将导致一一些缓存缺失,因而线程在首次调度运行时会更加缓慢。这就是为什么调度器会为每个可运行的线程分配一一个最小执行时间,即使有许多其他的线程正在等待执行:它将上下文切换的开销分摊到更多不会中断的执行时间上,从而提高整体的吞吐量(以损失响应性为代价)。
11.3.2 内存同步
现代的JVM能通过优化去掉一些不会发生竞争的锁,从而减少不必要的同步开销。如果一个锁对象只能由当前线程访问,那么JVM就可以通过优化去掉这个锁获取操作,因为另一个线程无法与当前线程在这个锁上发生同步。例如,JVM通常会去掉下面代码中的锁获取操作。
//没有作用的同步(不要这么做) synchronized (new Object()){ //do }
JVM也可以执行锁粗化(Lock Coarsening)操作,将临近的同步代码块用一个锁合并起来。如下面代码所示,3个add和1个toString调用合并为单个锁获取/释放操作。
public String getNames(){ List list = new Vector(); list.add("张三1"); list.add("张三2"); list.add("张三3"); return list.toString(); }
11.4 减少锁的竞争
在并发程序中,对可伸缩性的最主要微威胁就是独占方式的资源锁。
11.4.1 缩小锁的范围("快进快出")
降低发生竞争可能性的一种有效方法是尽可能的缩短锁的持有时间。
class Demo { private final Map map = new HashMap(); //错误案例 public synchronized void putIfAbsent(String name, String val) { String key = "users." + name + ".location"; if (!map.containsKey(key)) { map.put(key, val); } } //正确案例 public void putIfAbsent(String name, String val) { String key = "users." + name + ".location"; synchronized (this){ if (!map.containsKey(key)) { map.put(key, val); } } } }
11.4.2 减小锁的粒度
锁分解前
//使用一个锁 class Demo { private final Map map = new HashMap(); private final Set set = new HashSet(); public synchronized void putIfAbsentMap(String name, String val) { String key = "users." + name + ".location"; synchronized (this){ if (!map.containsKey(key)) { map.put(key, val); } } } public synchronized void putIfAbsentSet(String name) { String key = "users." + name + ".location"; synchronized (this){ if (!set.contains(key)) { set.add(key); } } } }
锁分解后
//锁分解 class Demo { private final Map map = new HashMap(); private final Set set = new HashSet(); public void putIfAbsentMap(String name, String val) { String key = "users." + name + ".location"; synchronized (map) { if (!map.containsKey(key)) { map.put(key, val); } } } public void putIfAbsentSet(String name) { String key = "users." + name + ".location"; synchronized (set) { if (!set.contains(key)) { set.add(key); } } } }
11.4.3 锁分段
每一段使用一个锁。可以看jdk1.8以前(不包括jdk1.8)的ConcurrentHashMap中的分段锁。
//锁分段 class Demo { //比如我设置16个锁 Object[] locks = new Object[16]; private int[] data = new int[32]; public void updateData(int index, int val) { synchronized (locks[index % 16]) { data[index] = val; } } }
锁分段的一个劣势在于:与采用单个锁来实现独占访问相比,要获取多个锁来实现独占访问将更加困难并且开销更高。通常,在执行一个操作时最多只需要获得一个锁,但是在某些情况下需要加锁整个容器,例如当ConcurrentHashMap需要扩展映射范围以及重新计算键值的散列值要分布到更大的桶集合中时,就需要获取分段所集合中所有的锁。
11.4.5 一些替代独占锁的方法
并发容器、读-写锁、不可变对象以及原子变量。
11.4.7 向对象池说“不”
通常,对象分配操作的开销比同步的开销更低。
小结
由于使用线程常常是为了充分利用多个处理器的计算能力,因此在并发程序性能的讨论中,通常更多地将侧重点放在吞吐量和可伸缩性上,而不是服务时间。Amdahl定律告诉我们,程序的可伸缩性取决于在所有代码中必须被串行执行的代码比例。因为Java程序中串行操作的主要来源是独占方式的资源锁,因此通常可以通过以下方式来提升可伸缩性:减少锁的持有时间,降低锁的粒度,以及采用非独占的锁或非阻塞锁来代替独占锁。
第13章 显式锁
13.1 Lock与ReentrantLock
Lock提供了一种无条件的、可轮询的、定时的以及可中断的锁获取操作,所有的加锁和解锁的方法都是显示的。ReentrantLock实现了Lock接口,并且提供了与synchronized相同的互斥性和内存可见性。
- 轮询锁与定时锁
- 可中断的锁获取操作
- 非块结构的加锁
Lock接口的标准使用形式如下:
Lock lock = new ReentrantLock(); lock.lock(); try { //do logic } catch (Exception e) { e.printStackTrace(); } finally { lock.unlock(); }
13.2 性能考虑因素
性能是一个不断变化的指标,如果在昨天的测试基准中发现X比Y更快,那么在今天可能已经过时了。
13.3 公平性
在ReentrantLock的构造函数中提供了两种公平性选择:创建一 个非公平的锁(默认)或者一个公平的锁。在公平的锁上,线程将按照它们发出请求的顺序来获得锁,但在非公平的锁上,则允许“插队”:当一个线程请求非公平的锁时,如果在发出请求的同时该锁的状态变为可用,那么这个线程将跳过队列中所有的等待线程并获得这个锁。(在Semaphore中同样可以选择采用公平的或非公平的获取顺序。)非公平的ReentrantLock并不提倡“插队”行为,但无法防止某个线程在合适的时候进行“插队”。在公平的锁中,如果有另一个线程持有这个锁或者有其他线程在队列中等待这个锁,那么新发出请求的线程将被放入队列中。在非公平的锁中,只有当锁被某个线程持有时,新发出的请求的线程才会被放入队列中。
等执行加锁操作时,公平性将由于在挂起线程和恢复线程时存在的开销而极大的降低性能。在大多数情况下,非公平性锁的性能要高于公平性锁。
在激烈竞争的情况下,非公平锁的性能高于公平锁的性能的一个原因是:在恢复一个被挂的线程与该线程真正开始运行之间存在着严重的延迟。假设线程A持有一个铺,并且线程B青求这个锁。由于这个锁已被线程A持有,因此B将被挂起。当A释放锁时,B将被唤醒,因北会再次尝试获取锁。 与此同时,如果C也请求这个锁,那么C很可能会在B被完全嗅醒之前获得、使用以及释放这个锁。这样的情况是一种“双赢”的局面:B获得锁的时刻并没有推迟,C更早的获得锁,并且吞吐量也获得了提高。
13.4 在synchronized和ReentrantLock之间进行选择
在一些内置锁无法满足需求的情况下,ReentrantLock可以作为一种高级工具。当需要一些高级功能时才应该使用ReentrantLock,这些功能包括:可定时的、可轮询的与可中断的锁获取操作,公平队列已经非块结构的锁。否则,还是应该优先使用synchronized。
13.5 读写锁
当访问以读操作为主的数据结构时,它能提高程序的可伸缩性。
ReadWriteLock lock = new ReentrantReadWriteLock(); Lock writeLock = lock.writeLock(); Lock readLock = lock.readLock(); writeLock.lock(); try { } catch (Exception e) { e.printStackTrace(); } finally { writeLock.unlock(); }
第14章 构建自定义的同步工具
14.1 状态依赖的管理
通常,如果线程在休眠或者被阻塞时持有一个锁,那么这通常是一种不好的做法,因为只要线程不释放这个锁,有些条件就永远无法成真。
“条件队列”这个名字来源:它使得一组线程(称之为等待线程的集合)能够通过某种方式来等待特定的条件变为真。传统队列的元素是一个个数据,而与之不同的是,条件队列中的元素是一个个正在等待相关条件的线程。
14.2 使用条件队列
14.2.1 条件谓语
条件谓语是使某个操作称为状态依赖操作的前提条件。在阻塞队列中,只有当队列不为空 时,take方法才能执行,否则必须等待。对take方法来说,它的条件谓词就是“队列不空”。
每一次wait调用都会隐式的域特定的条件谓语关联起来。当调用某个特定条件谓词的wait时,调用者必须已经持有与条件队列相关的锁,并且这个锁必须保护这构成条件谓词的状态变量。
14.2.2 过早唤醒
当使用条件等特时(例如Object.wait或Condition.await):
●通常都有一个条件谓词一 包括一 些对象状态的测试,线程在执行前必须首先通过这些测试。
●在调用wait之前测试条件谓词,并且从wait中返回时再次进行测试。
●在一个循环中调用wait。
●确保使用与条件队列相关的锁来保护构成条件谓词的各个状态变量。
●当调用wait、 notify或notifyAll等方法时,一定要持有与条件队列相关的锁。
●在检查条件谓词之后以及开始执行相应的操作之前,不要释放锁。
14.2.3 丢失的信号
只有同时满足以下两个条件时,才能使用单一的notify而不是notifyAll:
所有等待线程的类型相同。只有一个条件谓语与条件队列相关,并且每个线程在从wait返回后将执行相同的操作。
单进单出。 在条件变量上每次通知,最多只能唤醒一个线程来执行。
14.3 显式的Condition对象
在Condition对象中,与wait、notify和notifyAll方法对应的分别是await、signal和signalAll。但是,Condition对Object进行可扩展,因此也包含wait、notify和notifyAll方法。一定要使用正确的版本。
14.5 AbstractQueuedSynchronizer(AQS)(☆☆☆☆☆)
很重要,但是书中将的很粗略
14.6 java.util.concurrent同步器类中的AQS(☆☆☆☆☆)
ReentrantLock
Semaphore与CountDownLatch
FutureTask
ReentrantReadWriteLock
第15章 原子变量与非阻塞同步机制
15.1 锁的劣势
1)如果有多个线程同时请求锁,那么一些线程将被挂起并且稍后恢复运行。当线程恢复时,必须等待其他线程执行完他们的时间片以后,才能被调度使用。在挂起和恢复线程等过程中存在着很大的开销,并且通常存在着较长时间的中断。
2)当一个线程正在等待锁时,它不能做任何事情。如果一个持有锁的线程被延迟执行(例如发生了缺页错误、调度延迟等),那么所有需要这个锁的线程都无法执行下去。
3)如果被阻塞的线程的优先级较高,而持有锁的线程的优先级较低,那么这将是一个严重的问题-----优先级反转。
15.2 硬件对并发的支持
CAS的主要缺点:使调用者处理竞争问题(通过重试、回退、放弃),而在锁中能自动处理竞争问题(线程在获得锁之前一直阻塞)。
在大多数处理器上,在无竞争的锁获取和释放的“快速代码路径”上的开销,大约是CAS开销的两倍。
15.3 原子变量类
AtomicInteger、AtomicLong、AtomicReference、AtomicReferenceFieldUpdater、AtomicStampedReference、AtomicMarkableReference
锁与原子变量在不同竞争程度上的性能差异很好得说明了各自的优势和劣势。在中低程度的竞争下,原子变量能提供更高的可伸缩性。在高强度的竞争下,锁能干有效的避免竞争。
15.4 非阻塞算法
创建非阻塞算法的关键在于:找出如何将原子修改的范围缩小到单个变量上,同时还要维护数据的一致性。
第16章 Java内存模型(JMM)
此内容参考《深入理解java虚拟机》
Java内存模型(Java Memory Model ,JMM)就是一种符合内存模型规范的,屏蔽了各种硬件和操作系统的访问差异的,保证了Java程序在各种平台下对内存的访问都能得到一致效果的机制及规范。目的是解决由于多线程通过共享内存进行通信时,存在的原子性、可见性(缓存一致性)以及有序性问题。
原子性
线程是CPU调度的基本单位。CPU有时间片的概念,会根据不同的调度算法进行线程调度。所以在多线程场景下,就会发生原子性问题。因为线程在执行一个读改写操作时,在执行完读改之后,时间片耗完,就会被要求放弃CPU,并等待重新调度。这种情况下,读改写就不是一个原子操作。即存在原子性问题。
缓存一致性(可见性)
在多核CPU,多线程的场景中,每个核都至少有一个L1 缓存。多个线程访问进程中的某个共享内存,且这多个线程分别在不同的核心上执行,则每个核心都会在各自的caehe中保留一份共享内存的缓冲。由于多核是可以并行的,可能会出现多个线程同时写各自的缓存的情况,而各自的cache之间的数据就有可能不同。
在CPU和主存之间增加缓存,在多线程场景下就可能存在缓存一致性问题,也就是说,在多核CPU中,每个核的自己的缓存中,关于同一个数据的缓存内容可能不一致。
有序性
除了引入了时间片以外,由于处理器优化和指令重排等,CPU还可能对输入代码进行乱序执行,比如load->add->save 有可能被优化成load->save->add 。这就是有序性问题。
Java内存模型规定了所有的变量都存储在主内存中,每条线程还有自己的工作内存,线程的工作内存中保存了该线程中是用到的变量的主内存副本拷贝,线程对变量的所有操作都必须在工作内存中进行,而不能直接读写主内存。不同的线程之间也无法直接访问对方工作内存中的变量,线程间变量的传递均需要自己的工作内存和主存之间进行数据同步进行。
总结
1)总结的不是很到位,有些没看懂就省略了。
2)学习知识是一个潜移默化的过程,学完后也许看不出成果。
3)年轻人的世界没有容易的,每天进步,足矣。
参考
《java并发编程实战》