java并发编程艺术总结

第1章 并发编程的挑战
1.1 上下文切换即便是单核CPU也支持多线程并发,CPU通过给每个线程分配时间片(几十毫秒)来实现并发的机制。通过不停切换线程,使得多个任务并发处理。任务从保存到再加载的过程就是一次上下文切换。由于上下文切换以及线程创建的开销,可能会导致并发执行的速度比串行执行要慢。通过无锁并发编程,CAS算法,使用最少线程和使用协程可以减少上下文切换。
1.2 避免死锁避免死锁的常见方法:避免一个线程同时获取多个锁。避免一个线程在锁内占用多个资源,尽量保证每个锁只占用一个资源。尝试使用定时锁来代替内部锁机制。对于数据库锁,加锁和解锁必须在一个连接里,否则会出现解锁失败的情况。
1.3 资源限制资源限制是指在进行并发编程时,程序的执行速度受限于硬件或软件资源。例如带宽,硬盘读写,以及CPU处理速度,数据库连接数等等。并发编程中,将代码执行速度加快的原则是将代码中串行执行的部分变成并行。但如果受限于资源,并发的代码仍然会串行执行。通过集群或资源池复用可以帮助缓解资源限制问题,根据不同的资源限制调整并发度。
第2章 Java并发机制的底层实现原理java代码经过编译会变成字节码,然后被类加载器加载到JVM中,JVM执行字节码,最终转化为汇编指令在CPU上执行,而Java所使用的并发机制依赖于JVM的实现和CPU的指令。
2.1 volatile的应用volatile是一个轻量级的synchronized,在多CPU开发中保证了共享变量的“可见性”,也就是说当一个线程修改一个共享变量的时候,另一个线程能够读取到所修改的值。如果volatile使用恰当的话,它将比synchronized的使用和执行成本更低,不会引起上下文的切换和调度。Java允许线程访问共享变量,为了确保共享变量能被准确和一致地更新,线程应该确保通过排它锁单独获取这个变量。如果一个变量被声明为volatile,Java线程内存模型确保所有线程看到这个变量的值是一致的。volatile修饰的共享变量在转换为汇编语言后,会出现Lock前缀指令,该指令在多核处理器下引发了两件事:
1、将当前处理器缓存行(CPU cache中可以分配的最小存储单位)的数据写回到系统内存。
2、这个写回内存的操作使得其他CPU里缓存了该内存地址的数据无效。
为了提高处理速度,CPU不直接和内存通信,而是将内存数据读取到cache后进行操作,但何时写回到内存是不确定的。如果线程volatile变量进行了写操作,则JVM会向CPU发送一条Lock前缀指令,将该变量的所在的cache行的数据写回到内存中。同时,为了保证其他CPU所读取到的cache值是一致的,就户实现cache一致性协议,每个CPU通过嗅探在总线上传播的数据来检查自己所缓存的值是否过期。如果CPU发现自己cache行中所对应的内存地址被修改,就会将该cache行设置为无效,从而在对该数据进行修改的时候重新从内存中读取
。volatile的两条实现原则:
1、Lock前缀指令会引起CPU cache写回到内存。
2、一个CPU的cache写回到内存会导致其他处理器缓存无效。
2.2 synchronized的应用synchronized实现同步的基础是:Java中每个对象都可以作为锁,具体表现为以下三种形式:
1、对于普通同步方法,锁是当前实例对象;
2、对于静态同步方法,锁是当前类的class对象;
3、对于同步方法块,锁是synchronized括号里配置的对象
JVM基于进入和退出monitor对象来实现方法的同步和代码块同步,但是两者细节不同。代码块同步是使用monitorenter和monitorexit指令实现。monitorenter和monitorexit指令是在编译后插入到同步代码块开始和结束的的位置。任何一个对象都有一个monitor与之关联,当一个monitor被持有之后,将处于锁定状态。线程执行到monitorenter指令时,会尝试获取所有对象对应的monitor所有权,也即获得对象的锁。synchronized所用到的锁是存在Java对象头中。在Java1.6中,锁一共有4种状态,由低到高依次是:无锁,偏向锁,轻量级锁,重量级锁,这几种状态会随着竞争情况逐渐升级。CAS操作的意思是比较并交换,它需要两个数值,一个旧值(期望操作前的值)和新值。操作之前比较两个旧值是否变化,如无变化才交换为新值。在硬件层面,CPU依靠总线加锁和缓存锁定机制来实现原子操作。
使用总线锁保证原子性。如果多个CPU同时对共享变量进行写操作(i++),通常无法得到期望的值。CPU使用总线锁来保证对共享变量写操作的原子性,当CPU在总线上输出LOCK信号时,其他CPU的请求将被阻塞住,于是该CPU可以独占共享内存。
使用缓存锁保证原子性。频繁使用的内存地址的数据会缓存于CPU的cache中,那么原子操作只需在CPU内部执行即可,不需要锁住整个总线。缓存锁是指在内存中的数据如果被缓存于CPU的cache中,并且在LOCK操作期间被锁定,那么当它执行锁操作写回到内存时,CPU不使用总线锁,而是修改内部的内存地址,并允许它的cache一致性来保证操作的原子性,当其他CPU回写被锁定的cache行数据时候,会使cache行无效。
Java使用了锁和循环CAS的方式来实现原子操作。使用循环CAS实现原子操作。JVM的CAS操作使用了CPU提供的CMPXCHG指令来实现,自旋式CAS操作的基本思路是循环进行CAS操作直到成功为止。1.5之后的并发包中提供了诸如AtomicBoolean, AtomicInteger等包装类来支持原子操作。CAS存在ABA问题,循环时间长开销大,以及只能保证一个共享变量的原子操作。使用锁机制实现原子操作。锁机制保证了只有获得锁的线程才能给操作锁定的区域。JVM的内部实现了多种锁机制。除了偏向锁,其他锁的方式都使用了循环CAS,也就是当一个线程想进入同步块的时候,使用循环CAS方式来获取锁,退出时使用CAS来释放锁。第三章 Java内存模型Java内存模型基础在并发编程中,线程间如何通信和线程见如何同步时需要处理的两个问题。在命令式的编程中,线程之间的通信主要依靠内存共享和消息传递。同步时指程序中用于控制不同线程之间操作发生相对顺序的机制。在共享内存并发模型中,需要显式指定某个方法或代码需要在线程之间互斥执行。在Java中,堆内存在线程之间共享,线程之间的通信由Java内存模型JMM控制。线程之间的共享变量存储在主内存中,每个线程都有一个私有的本地内存(并不真实存在),本地内存中存储了线程读写共享变量的副本。在执行程序时,为了提高性能,编译器和CPU常常会对指令进行重排序,分为以下3种类型:
1、编译优化重排序。编译器在不改变单线程程序语义的前提下,可以重新安排语句执行顺序。
2、指令级并行的重排序。CPU采用了指令级并行技术将多条指令重叠执行。
3、内存系统的重排序。由于CPU使用cache和读/写缓冲区,因此加载和存储操作可能在乱序执行。
Java源代码到最终实际执行的指令序列,会分别经过上述3种重排序。这些重排序可能会导致多线程程序出现内存可见性问题。JMM属于语言级别的内存模型,确保在不同编译器和不同CPU平台之上,通过禁止特定类型的编译器重排序和CPU重排序,为开发者提供一致的内存可见性保证。现代CPU通过cache来保存向内存中写入的数据。cache可以保证指令流水线持续运行,可以避免由于CPU停顿下来等待向内存写入数据而产生的延迟。通过批处理的方式刷新cache,合并写cache对于同一内存地址的多次写操作,可以减少总线的占用。然而,每个CPU的cache只对它所在的CPU可见,这将会导致出现对内存的读写并发问题。因此,CPU都会允许对W-R操作进行重排序。为了保证内存可见性,Java编译器在生成指令序列的适当位置会插入内存屏障指令来禁止特定类型CPU重排序。JDK1.5后,Java采用JSR133内存模型。通过happens-before概念来阐述操作之间的内存可见性。在JMM中,如果一个操作执行的结果要对另一个操作可见,那么这两个操作之间必须要有happens-before关系。这两个操作可以在同一个线程中,也可以在不同的线程中。(两个操作之间具有happens-before关系,不意味着前一个操作必须要在后一个操作之前执行,仅仅要求前一个操作的执行结果对后一个操作可见)重排序重排序是指编译器和处理器为了优化程序性能而对指令序列进行重新排序的一种手段。对于单个CPU和单个线程中所执行的操作而言,如果两个操作都访问了一个变量,且两个操作中有写操作,那么这两个操作就具有依赖性。(RW,WW,WR)这三种操作只要重排序对操作的执行顺序,程序的执行结果就会被改变,因此,编译器和处理器在进行重排序的时候会遵守数据依赖性,不会改变存在数据依赖关系的两个操作的执行顺序。as-if-serial:无论如何重排序,(单线程)程序的执行结果不能被改变。编译器,runtime,CPU都必须遵守as-if-serial语义,因此,编译器和CPU不会对存在数据依赖关系的操作进行重排序。在单线程中,对存在控制依赖性的操作进行重排序,不会改变执行结果,而在多线程中则可能会改变结果。顺序一致性程序未正确同步的时候,就可能存在数据竞争:在一个线程中写一个变量,在另一个线程中读同一个变量,而且写和读没有通过同步来排序。JMM对正确同步的多线程程序的内存一致性做了如下保证:如果程序是正确同步的,程序的执行将具有顺序一致性,程序的执行结果与该程序的顺序一致性内存模型的执行结果相同。JMM中,临界区内的代码可以重排序。而对于未正确同步的多线程程序,JMM只提供最小的安全性:线程执行时所读取到的值,要么是之前某个线程所写入的值,要么是默认值。volatile的内存语义一个volatile变量的单个R/W操作,与一个普通变量的R/W操作使用同一个锁来同步,它们的执行效果相同。锁的happens-before规则保证释放锁和获取锁的两个线程之间的内存可见性,这也意味着对一个volatile变量的R操作,总是能看到任意线程对该变量最后的写入。对于volatile变量本身的单个RW操作具有原子性,但是与锁不同的是,多个对于volatile变量的复合操作不具有原子性。而锁的语义保证了临界区代码的执行具有原子性。JAVA1.5后,JSR-133增强了volatile的内存语义,严格限制编译器和CPU对于volatile变量与普通变量的重排序,从而确保volatile变量的W-R操作可以实现线程之间的通信,提供了一种比锁更轻量级的线程通信机制。从内存语义的角度而言,volatile的W-R与锁的释放-获取有相同的内存效果:W操作=锁的释放;R操作=锁的获取。A线程写一个volatile变量x后,B线程读取x以及其他共享变量。1. 当A线程对x进行写操作时,JMM会把该线程A对应的cache中的共享变量值刷新到主存中.(实质上是线程A向接下来要读变量x的线程发出了其对共享变量修改的消息)2.当B线程对x进行读取时,JMM会把该线程对应的cache值设置为无效,而从主存中读取x。(实质上是线程B接收了某个线程发出的对共享变量修改的消息)两个步骤综合起来看,在线程B读取一个volatile变量x后,线程A本地cache中在写这个变量x之前所有其他可见的共享变量的值都立即变得对B可见。线程A写volatile变量x,B读x的过程实质上是线程A通过主存向B发送消息。需要注意的是,由于volatile仅仅保证对单个volatile变量的R/W操作具有原子性,而锁的互斥则可以确保整个临界区代码执行的原子性。(参见《Java理论与实践:正确使用volatile变量》)锁的内存语义锁是Java编程中最重要的同步机制,除了让临界区互斥执行之外,还可以让释放锁的线程向获取锁的线程发送消息。当线程释放锁时,JMM会把该线程对应的本地cache中的共享变量刷新到主存中。当线程获取锁时,JMM会把该线程对应的本地内存置为无效,从而使得临界区的代码必须从主存中读取共享变量。对比锁和volatile的内存语义可以看出:锁的释放与volatile的写操作有相同的内存语义,锁的获取与volatile的读操作有相同的内存语义。第四章 Java并发编程基础线程作为操作系统调度的最小单元,都拥有各自的计数器,堆栈和局部变量等属性,并且能够访问共享的内存变量。不同的JVM以及OS上,线程规划会存在差异,有些OS甚至会忽略对线程优先级的设定。Java线程在生命周期中可以处于6种不同的状态:
NEW:初始状态,线程被构建,但没有调用start方法
RUNNABLE:运行状态,Java线程将操作系统中的就绪和运行两种状态统称为运行中
BLOCKED:阻塞状态,表示线程阻塞于锁
WAITING:等待状态,表示当前线程需要等待其他线程做出特定的通知或中断
TIME_WAITING:超时等待状态,它可以在指定时间自行返回
TERMINATED:终止状态,表示当前线程以及执行完毕

Java线程状态变迁
Deamon线程是一种支持型线程,因为它主要被用作程序中后台调度以及支持性工作。当JVM中不存在非Deamon线程的时候,JVM将会退出。因此,在构建Deamon线程时,不能依靠finally来确保执行关闭或者清理资源的逻辑。启动和终止线程在运行线程之前首先要构造一个线程的对象,并提供所需的属性。一个新构建的线程对象是由其parent线程进行空间分配的,而子线程继承了parent线程是否为Deamon线程,优先级,以及threadLocal等。线程的中断状态是线程的一个标识位,表示一个运行中的线程是否被其他线程进行了中断操作,也是一种简便的线程间交互方式,适合用来取消或停止任务。通过中断或标识位的方式对任务进行终止更加安全。线程间通信Java 线程在运行过程中拥有自己的栈空间,为了提高程序的执行速度,线程在栈空间内保存了变量的副本。volatile保证了所有线程对其所修饰的成员变量的可见性,synchronized可以修饰方法或同步块,保证了线程对变量访问的可见性和排他性。同步块的实现采用了monitorenter和monitorexit指令,而同步块方法则依靠方法修饰符的ACC_SYNCHRONIZED来完成。但其本质上都是采用了对一个对象的监视器进行获取,这个过程是排他的。任何一个对象都拥有自己的monitor,这个对象被同步块或者该对象的同步方法调用时,执行方法的线程必须获取到该对象的monitor,这个获取过程是排他的。未获取到monitor的线程将会被阻塞于同步块或方法的入口处,进入BLOCKED状态。等待/通知机制,是指一个线程A调用了对象O的wait方法进入了等待状态,而另一个线程B调用了对象O的notify方法或者notifyAll方法,线程A收到通知后从对象O的wait方法返回,进而执行后续操作。在调用对象的wait,notify,notifyAll方法之前要对该对象加锁,调用对象O的wait方法后会释放对象O的锁。在调用notify和notifyAll方法后,等待线程并不会返回,需要通知线程释放锁之后才能从wait方法返回。notify方法将等待线程从等待队列放入同步队列,线程由WAITING状态进入BLOCKED状态。管道的IO流与普通文件IO流的不同,它主要用于线程之间的数据传输。如果一个线程A执行了B.join方法,则线程A直到B线程终止后才从该方法中返回。第五章 Java中的锁锁是用来控制多个线程访问共享资源的方式。Lock接口提供了显式获取和释放锁的方式的同时,也提供了非阻塞性获取锁,中断锁以及超时锁等synchronized不具备的特性。队列同步器是用来构建锁或其他同步组件的基础框架,其主要使用方式是继承。它使用一个int成员变量表示同步状态,通过内置的FIFO队列来完成资源获取线程的排队工作。同步器是实现锁以及其他同步组件的关键。锁是面向使用者的,定义了使用者与锁交互的接口,隐藏了实现细节。同步器面向的是锁的实现者,简化了锁的实现方式,实现了锁的语义。重入锁表示该锁能够支持一个线程对资源重复加锁,还支持获取锁时候的公平性选择。公平锁保证了锁按照FIFO原则,而代价是大量的线程切换。独占锁和重入锁都是排他锁,同一时刻只允许一个线程访问临界区。而读写锁在同一时刻可以允许多个线程访问,但在写线程访问时,其他读写线程均被阻塞。读写锁维护了一个写锁和一个读锁,通过分离读锁和写锁使并发性比一般排他锁有了更大的提升。任意Java对象,都拥有一组监视器方法,主要包括wait,notify,notifyAll方法,这些方法与synchronized配合可以实现等待/通知模式。而Condition接口同样提供了类似Object的监视器方法,与Lock配合同样可以实现该模式。第六章 Java并发容器和框架ConcurrentHashMap
并发编程中HashMap不是线程安全的容器,多线程会导致HashMap的Entry链表形成环形数据结构,在并发执行put操作时会引起死循环。HashTable使用synchronized来保证线程安全,在线程竞争激烈的情况下效率不高。一个线程访问HashTable的同步方法时,其他访问线程必须竞争同一把锁,会进入阻塞或轮询状态。ConcurrentHashMap使用锁分段的技术,将数据分成一段一段地存储,每段数据配一把锁,当一个线程访问一个数据段时其他数据段也可以被其他线程访问。ConcurrentHashMap由Segment数组结构和HashEntry数组结构组成。Segment是一个钟可重入锁,而HashEntry则用于存储键值对数据。一个ConcurrentHashMap里包含了一个Segment数组,与HashMap类似,Segment是一种数组和链表结构。每个Segment中包含了一个HashEntry数组,HashEntry是一个链表结构的元素。当对HashEntry中元素进行修改时,需要获得与之对应的Segment锁。

ConcurrentLinkedQueue在并发编程中,实现一个安全队列的方法有两种:阻塞算法和非阻塞算法。阻塞算法利用锁来保证队列的并发操作,而非阻塞算法使用循环CAS的方式来实现。ConcurrentLinkedQueue采用了非阻塞的方式来实现。第八章 Java中的并发工具类CountDownLatchjoin方法用于让当前线程等待join线程执行结束,其原理是不停检查join线程是否存活,直到join线程终止。CountDownLatch作为计数器也可以实现join的功能,当调用countDown方法时计数器减1,并调用await方法阻塞当前线程,直到计数器为0.CyclicBarrierCyclicBarrier能够让一组线程达到一个屏障(同步点)时候被阻塞,直到最后一个线程到达屏障后,所有被阻塞的线程才能继续执行。它可以用于多线程计算数据,最后合并计算结果的场景。CountDownLatch的计数器只能使用一次,而CyclicBarrier可以使用reset方法重置。SemaphoreSemaphore信号量是用来控制同时访问特定资源的线程数量,通过协调各个线程保证合理使用公共资源,可以用于流量控制,例如数据库连接。第九章 Java中的线程池Java中的线程池可以降低资源消耗,提高响应速度,提高线程的可管理性。当一个新任务提交到线程池后,处理流程如下:
1、线程池判断核心线程池里的线程是否都在执行任务。如果不是,则创建一个新的工作线程来执行任务;如果核心线程池已满,则进入下一步。()
2、线程池判断工作队列是否已满。如果工作队列未满,则将新任务存储于工作队列中。如果工作队列也满了,则进入下一步。
3、线程池判断线程池中的线程是否都处于工作状态。如果没有,则创建一个新的工作线程来执行任务,如果线程池已满,则交给饱和策略处理该任务。
要想合理配置线程池,就必须首先分析任务特性:
任务性质:CPU密集型,IO密集型,混合任务。
优先级:高,中,低。
任务执行时间:长,中,短。
任务的依赖性:是否依赖其他系统资源,例如数据库连接。
性质不同的任务可以用不同规模的线程池分开处理。CPU密集型的任务应配置尽可能小的线程数,如配置核数N+1个线程的线程池。对于IO密集型的任务,CPU并不是一直在执行,则应该配置尽可能多的线程数,例如2*N。对于混合型任务,如果可以拆分任务且任务执行时间差不多,则将任务拆成不同性质的小任务来执行。优先级不同的任务可以使用优先级队列PriorityBlockingQueue来处理,让优先级高的任务先执行。执行时间不同的任务可以交给不同规模的线程池来处理,或者使用优先级队列来让时间短的任务先执行。依赖数据库连接池的任务。等待时间越长,CPU闲置时间越长,则线程池线程数目越大越能更好利用CPU。第10章 Executor框架Java的线程既是工作单元(runnable,callable)也是执行机制(Executor)。Java多线程程序通常将应用分解为若干个任务,然后使用Executor框架控制上层调度,而下层调度由操作系统内核控制。Executor框架由3大部分组成:
任务,被执行任务需要实现接口:Runnable或Callable接口。
任务的执行,包括任务执行机制的核心接口Executor,以及继承自Executor的ExecutorService接口。Executor框架中ThreadPoolExecutor和ScheduledThreadPoolExecutor类实现了ExecutorService接口。
异步计算结果,包括接口Feature和实现了Feature接口的FeatureTask类。
Runnable或Callable接口的实现类都可以被线程池执行,Runnable无法返回结果,Callable可以返回结果。Executor接口是Executor框架的基础,它将任务的提交与任务的执行分离开来。它的实现类ThreadPoolExecutor和ScheduledThreadPoolExecutor都由使用工厂类Executors来创建。ThreadPoolExecutor:
FixedThreadPool:固定线程数的线程池。适用于为了满足资源管理的需求而需要限制当前数量的应用场景,例如负载较重的服务器。
SingleThreadPoolExecutor:单个线程的线程池。适用于需要保证顺序地执行各个任务。
CachedThreadPool:能够根据需要创建新线程的线程池。大小无界,适用于执行很多短期异步的小程序,或者负载较轻的服务器。
ScheduledThreadPoolExecutor可以给定延迟后运行命令。
ScheduledThreadPoolExecutor:固定线程数的线程池。适用于需要多个后台线程执行周期任务,同时为了满足资源管理需求限制后台线程数量的场景。
SingleScheduledThreadPoolExecutor:适用于需要单个后台线程执行周期任务,同时需要包装顺序执行各个任务的应用场景。
Feature接口及其实现类FeatureTask用来表示异步计算的结果。当把任务提交给线程池后,线程池会返回FeatureTask对象。通过执行FeatureTask.get方法来等待任务执行完成,完成后该方法返回任务执行的结果。

链接:https://www.jianshu.com/p/77ba860aef4b

猜你喜欢

转载自blog.csdn.net/u010417597/article/details/85779493