Java并发编程实战-总结

Posted VoidMe

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Java并发编程实战-总结相关的知识,希望对你有一定的参考价值。

每一个想学习Java多线程的人,手里至少有这本书或者至少要看这本书,2012年在看这本书的时候,当时正开发支付平台的后台应用,正好给了我大量的实践机会。强烈建议大家多看几遍。

 

代码中比较容易出现bug的场景:

不一致的同步,直接调用Thread.run,未被释放的锁,空的同步块,双重检查加锁,在构造函数中启动一个线程,notifynotifyAll通知错误,Object.waitCondition.await未在同步方法或块中调用,把Lock当锁用,调用Condition.wait方法,在休眠或等待时持有锁,自旋循环.

 

1.多线程可以提高资源的利用率,可以充分利用现代多核处理器的特性,让每个线程负责处理同类型的任务,更加容易维护,同时通过异步处理提高响应性。

 

2.多线程之间为更方便的实现数据共享采用了共享相同内存地址空间的形式,并且是并发运行,导致多个线程可能会同时访问或修改其他线程正在使用的变量值,导致安全性,同时如果线程之间相互等待对方拥有的锁,会出现活跃性即死锁问题。如果线程计算部分不多,更多的线程只会导致频繁的切换上下文,让CPU的时间更多的花在线程调度而不是任务执行上。

 

3.java同步的几种方式:synchronized,volatile,显示锁,原子变量,线程及对象的基础同步方法。

 

4.所谓线程安全就是当多个线程访问某个类时,不管运行时环境采用何种调度方式或者这些线程将如何交替执行,并且在主调代码中不需要任何额外的同步或协同,这个类都能表现出正确的行为。

 

5.将复合操作放在一个原子操作中执行,或用相同的锁来保护每个共享的和可变的变量。

 

6.增加同步必然会导致代码的复杂性,为性能牺牲代码简单性时不要太盲目,因为越复杂的代码,其不安全性越大。

 

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

 

8.线程之间变量的读取,在没有同步的情况下,编译器,处理器,以及运行时都有可能对部分指令进行重排导致并发问题。日常开发中常见的set/get,如果没有都加上synchronized,在多线程环境下也存在同样的问题。

 

9.对于非volatile64longdouble,由于JVM允许对他们的读取分解为高低32位来读取,多线程下会发生只读取部分32位的问题,因此对这些变量,要用volatile或锁保护读取。

 

10.volatile变量,编译器和运行时不会将该变量上的操作与其他内存操作一起重排序,也不会被缓存在寄存器或者对其他处理器不可见的地方,而是直接同步到内存,保证其他线程读取的时候返回最新写入的值。确切的说,volatile变量只保证可见性,对于自增或自减的操作并不能保证其原子性,因此不是线程安全的。因此不要过多的依赖此对象,最好在满足以下全部三个条件的情况下才考虑使用:

          (a)对变量的写入不依赖当前变量的值,即所谓的不是自增或自减情况,或者可以保证只有单个线程对其更新

          (b)该变量不参与到不变性条件的判断

          (c)访问该变量时不用加锁

另一方面:当且仅当一个变量参与到包含其他状态变量的不变性条件时,才可以声明为volatile类型。

 

11.发布对象的几种方式:1.将对象的引用保存到其他代码中或public域中。 2.非私有方法返回该对象引用或该对象引用作为参数传递。 3.发布Collections组合. 4.通过已发布对象的非私有变量引用或方法获取到的对象   5.类的内部类实例隐含的包含了对该类实例的引用。

 

12.正确的或安全的发布一个对象,即是保证对象的引用以及对象的状态必须同时对其他线程可见。不正确的发布可变对象会导致线程安全问题,以下是一些保证变量或对象线程安全的方法:

        1.不要在构造过程中使this逸出,即不要在构造函数中创建并启动线程,不要调用可改写的方法,或注册事件监听或对内部类实例化。   2.多使用线程封闭,即尽量把对象放在单线程中不参与共享.   3.使用栈封闭,即在方法内部用局部变量访问对象。   4.ThreadLocal封装变量,为每个线程提供一个只属于该线程的变量副本。可以视ThreadLocal<T>Map<Thread,T>,另外还有一个好处就是当线程终止后,该值也会被回收。(缺点:ThreadLocal变量类似全局变量,会降低代码的可重用性,并在类之间引入隐含的耦合性)  5.多用不可变对象(对象正确创建未this逸出,且创建后其状态不能修改,且所有域都是final),其一定是线程安全的。 6.静态初始化函数中初始化一个对象引用(JVM内部的同步机制保证了这种发布方式的安全性) 7.将对象的引用保存到volatile类型的域,AtomicReferance对象,某个正确构造对象的final域,或一个由锁保护的域中。  8.将对象放入线程安全的容器中可以由容器内部的同步机制保障对象安全发布。

 

13.如果需要对一组数据以原子方式执行某个操作,为避免竞态条件,可以创建一个不可变类来包含这些数据(如果数据是数组或其他可变对象,该类对应的变量为clone的副本以保证不可变性),通过把这些数据保存到该不可变类的实例上,并且用volatile来确保该实例的可见性,这样可以保证线程操作数据的安全。如果该类对象是可变的,当然可以加锁来确保原子性。

 

14.使用同步和封转来保护对象状态即变量的不变性条件及后验条件,使得相关变量必须在单个原子操作中进行读取或更新。换句话说,借助原子性与封装性,满足状态变量的有效值或状态转化上的各种约束条件,使得状态变量有效转换,是确保线程安全的有效手段。

 

15.实例封闭即是将一个对象的所有访问代码路径都封装到另一个对象里,可以通过类私有变量,局部变量,单个线程里等方式,保证被封闭的对象不会逸出,不会超出它们既定的作用域。常见的例子如同步包装器工作如Collections.synchronizedList对容器对象的唯一引用以实现将底层容器对象封闭从而达到线程安全的目的。

 

16.委托现有的同步容器来保障线程安全一般对针对""上的存取,如果类身包含复合操作,则该类必须自己提供加锁机制来保证这些复合操作的原子性。

 

17.当为现有的类添加一个原子操作时,利用组合并用同一个锁来保护同步操作可以实现。

 

18.同步容器类:VectorHashtable,以及由Collections.synchronizedXxx等工厂方法包装的同步封装器类,它们实现线程安全的方式是:将它们的状态封装起来,并对每个公有方法都进行同步,使得每次只有一个线程能访问容器的状态。因此如果基于这些共有方法衍生出了一些新操作,必须注意这些操作有可能不是原子的从而引发同步问题。

缺点:同步容器将所有对容器状态的访问都串行化以实现它们的线程安全目的,因此当多个线程同时竞争锁时,吞吐量将严重降低,并发性能严重受到影响。(正是由于上述原因,java5后开始提供多种并发容器来代替同步容器,以极大地提高伸缩性并降低风险)

 

19.并发容器类:java5提供了多种并发容器以代替同步容器的低并发性,并增加了一些常见的复合操作,如if-not-add,替换,以及条件删除,使得这些复合操作原子化。列举及大致说明如下:

          a:ConcurrentHashMap:也是基于散列的Map,利用粒度更细的分段锁机制使得任意数量的读取线程可以并发访问Map,并使得一定数量的写入线程可以并发的修改Map,从而在并发访问下实现更高的吞吐量。

          b:CopyOnWriteArrayList(Set):保留一个指向底层基础数组的引用,每次修改对象时,都会复制创建并重新发布一个新的容器副本,由于复制底层数组需要一定的开销,因此这些容器仅适用于迭代操作远远多于修改操作的场景。

          c:Queue:用来保存一组等待处理的元素,如传统FIFOConcurrentLinkedQueue(非同步的)优先队列PriorityQueue,还有其他的,可参见API

          d:BlockingQueue:可阻塞的Queue,如LinkedBlockingQueue,ArrayBlockingQueue,PriorityBlockingQueue,以及无存储容量的SynchronousQueue(该容器的puttake方法会一致堵塞,直到有另一个线程已经准备好参与到交付过程,因此仅当有足够多的消费者,并且总是有一个消费者准备好获取交付工作时,才适合使用此同步队列)。所有这些阻塞队列适用于生产者-消费者模式。

          e:DequeBlockingDequejava6新增的双端及可阻塞双端队列,适用于工作密取模式。每个消费者拥有各自的双端队列,当完成自己的队列是可以从其他队列的尾部开始获取工作,从而减少竞争提供并发。

 

20.在同步容器显式迭代(for-each,Iterator)或隐式迭代(toString,hashCode,equals,contailsAll,removeAll,retainAll)过程中,修改容器会出现ConcurrentModificationExcepiton异常。但是在并发容器中,它们提供的迭代器不会抛出ConcurrentModificationException异常,因此不需要在迭代过程中对容器加锁。

 

21.同步工具类:它们提供了一些特定的结构化属性,封装了一些决定线程等待还是执行的状态,并提供了操作这些状态以及高效的等待同步工具类进入预期状态的方法。主要包括:

          a:CountDownLatch闭锁

          b:FutureTask

          c:Semaphore信号量

          d:CyclicBarrier栅栏

 

22.并发任务的抽象,首先是要找到单个任务的边界,尽量使得各任务相互独立,任务之间不相互依赖,大多数服务器应用都采用了自然的任务边界,即以独立的客户请求为边界。一般来说每项任务还应该表示应用程序的一小部分处理能力,从而使整个应用程序表现出更好的吞吐量和响应性。

 

23.如果任务的执行时间较长,创建过多的线程不仅会耗费JVM时间,消耗更多的内存资源,而且大量的线程将竞争CPU的有限资源,另外线程栈的地址空间也会限制创建过多的线程。

 

24.各种线程池的创建方式及各自的一些特点:

          a)newFixedThreadPool:固定长度的线程池,如果某个线程发生了未预期的Exception而结束,线程池会补偿一个新的线程。

          b)newCachedThreadPool:动态可缓存的线程池,如果当前线程池规模超过处理需求,则回收空闲线程,否则添加新线程。

          c)newSingleThreadExecuto:创建单个工作者线程来执行任务,如果这个线程异常结束,则会创建另一个线程来替代。可以确保依照任务在队列中的顺序串行执行(FIFO,LIFO,优先级等)

          d)newScheduledThreadPool:以延时或定时方式来执行任务的固定长度线程池。

 

25.ExecutorService扩展了Executor接口以提供解决执行服务生命周期的问题,ExecutorService生命周期有三种状态:运行,关闭和已终止。shutdown方法平缓关闭:不再接受新的任务,并等待已经提交的以及尚未开始执行的任务执行完成。 shutdownNow方法粗暴关闭:尝试取消所有运行中的任务,并且丢弃队列中尚未开始执行的任务。

 

26.ExecutorService关闭后提交的任务交由Rejected Execution Handler来处理,它会抛弃任务或使得execute方法抛出一个未检查的RejectedExecutionException。可以调用awaitTermination(通常调用它后会立即调用shutdown)来等待ExecutorService到达终止状态,或者调用isTerminated来轮询等待。

 

27.Timer类处理延迟任务与周期任务是有缺陷的,一是它在执行所有任务的时候只会创建一个线程,这样一旦某个任务执行时间超过间隔时间,后续任务将会连续执行或被丢弃。另外更严重的是,如果某个TimeTask抛出了未检查异常而终止了执行线程,那么整个Timer将被取消。在Java5后,不要再使用Timer。可以用DelayQueueScheduledThreadPoolExecutor组合构建自己的调度服务。

扩展:关于TimerScheduleExecutorService执行Runnable任务是否抛出异常对程序的影响差异比较:

        类型                                     

   任务catch异常

任务不catch异常        

     Scheduled线程池(多个线程数)

会循环执行,但主要在一个线程内

如果发生异常,控制台无异常堆栈打出,线程池也不再循环执行,但仍活着。                            

 

        Timer(多个Timer)

多个Timer都会按照循环策略执行

每个Timer抛出各自的异常堆栈Timer也同时终止,待全部Timer终止后程序退出

 

 

28.Executor框架中,已提交但尚未开始的任务可以取消,如果是已经开始执行的任务,只有当它们能响应中断时才可以取消。Future.get如果抛出了异常,会封装成ExecutionException,可以通过getCause来获取初始异常。

 

29.CompletionService将已经完成的任务按照完成顺序放置到其内置的BlockingQueue队列上,每次get取到的都是最新完成的任务结果。可以用Callable<Void>来表示无返回的任务。

 

30.invokeAll按照任务集合中迭代器的顺序将所有的Future添加到返回的集合中。invokeAll会等待所有任务完成或超时才返回结果,不像submit立即异步返回,因为invokeAll内部对FutureList做了循环get等待。

 

31.可以使用线程的中断以及类库中提供的中断支持来实现任务的可取消特性。每个线程都有一个boolean类型的中断状态,当中断线程时,此状态将设置为true。关于线程中断有三个方法:

          1)interrupt:线程实例方法,调用后中断实例线程,设置该实例线程的中断状态为true

          2)isInterrupted:线程实例方法,返回实例的中断状态ture/false

          3)interrupted:静态方法,将清除当前线程的中断状态,并返回线程之前的状态。注意:如果调用此方法清除当前线程的中断状态并返回了true,说明当前线程在中断之前就已经是"已中断"的状态了,如果你不做任何处理,那么之前的中断就被屏蔽掉了,可以通过抛出InterruptedException来响应中断或再次调用interrupt来恢复中断。

一旦一个线程被终止或正常结束,都不能再次调用start方法启动了,否则会抛出InvalidThreadStateException.

当一个方法由于等待某个条件变成真而阻塞时,需要提供一种取消机制。

 

32.常见的阻塞方法如Thread.sleep,Object.wait在阻塞时都会检查线程是否已中断,如果发现已中断,则会先清除中断状态,然后抛出InterruptException.(通常,可中断的方法会在阻塞或进行重要的工作前首先检查中断,以便能尽快的响应中断)因此,在线程里调用可抛出InterruptedException异常的阻塞方法时将使线程处于某种阻塞状态,如果这个方法被中断,那么它将努力提前结束阻塞状态。当我们调用这些阻塞方法时,最好遵循下面的两种方法之一:1)抛出此InterruptedException异常。         2)如果无法抛出异常,比如在Runnable中运行,那么也一定要捕获此异常,并调用当前线程上的interrupt方法恢复中断状态,使调用栈中的更高层代码看到此线程引发了中断。  最好不要捕获异常又不做任何响应,这样调用栈上的高层代码无法对中断采取处理措施,因为线程被中断的证据已经丢失了。

 

33.在非阻塞的状态下,如果调用线程实例的interrupt方法,只是设置了该实例的中断状态,并不会抛出InterruptException,因此如果你的代码没有明确触发InterruptException的地方,也就意味着该线程实例没有很好的响应中断,只是此中断状态将一直保持,直到调用interrupted明确清除之。

 

34.对于一些不支持取消但仍会调用可阻塞的方法操作,必须在循环中调用这些阻塞方法,并在发现中断后重新尝试调用,当然当这些方法检测到已中断会抛出InterruptException,应该记录这个状态,并在返回前调用interrupt恢复中断。永远不要在方法中调用"调用线程(宿主线程)"interrupt,因为你无法知道当前线程的中断策略,最好的方式是在方法内创建一个线程,并对它进行中断,因为你可以控制它的中断策略。

 

35.Future.get抛出InterruptExceptionTimeoutException时,如果你知道不再需要结果了,就可以调用Future.cancel来取消任务。

 

36.对于不可取消中断的阻塞,如Socket IO, File IO,等待内置锁,可以通过封装Thread或用newTaskFor,将阻塞方法的不可中断性转移到其能响应的异常上,如通过提供封装后的cancel方法,将不可中断的Socket IO读写方法在cancel中变为关闭Socket,这样readwrite将抛出IOException,这样可以将原本应该抛出InterruptException转变成了IOException.

 

37.对于非正常终止的线程,比如抛出了RuntimeException,如果想做一些清理工作,可以有两种方式,一是设置线程的setUncaughtExceptionHandler,通过一个实现Thread.UncaughtExceptionHandler接口的类做一些清理,另一个是在线程启动时注册一个关闭钩子Runtime.getRuntime().addShutdownHook,这样虚拟机在关闭的时候就会执行这些钩子方法。

 

38.Executor框架可以将任务的提交与任务的执行策略解耦开来,但是以下情形却需要明确指定执行策略以保障安全性及避免活跃性问题:

          a)依赖性任务:提交给线程池的任务需要依赖其他任务,则会隐含的约束执行策略

          b)单线程环境任务:单线程的Executor下执行任务隐含的使用线程封闭机制保障了线程安全,如果切换到多线程环境下,会可能导致并发

          c)对时间响应敏感的任务:如果这些任务与其他时间较长的任务同时提交给线程池,在单线程及包含少量线程的Executor下会影响敏感任务的执行

          d)使用ThreadLocal的任务:由于线程池会动态的回收或增加线程,因此只有当线程本地址的生命周期受限于任务的生命周期时,在线程池中的线程使用ThreadLocal才有意义,而且不应该使用ThreadLocal在任务之间传递值。

 

39.只要线程池中的任务需要无限期的等待一些必须由池中其他任务才能提供的资源或条件,除非线程池足够大,否则将发生饥饿死锁.因此线程池中最好运行那些同类型并且相互独立的任务,以使线程池达到最大性能。如果确实需要执行不同类型的任务,应该考虑使用多个线程池。

 

40.线程池设置大小公式:Nthread = Ncpu * Ucpu(cpu目标利用率) * (1+W/C(等待时间与计算时间比))。对于计算密集型任务,在拥有N个处理器的系统上,当线程池的大小为N+1时,通常能实现最优利用率。如果是其他资源限制,那么用该资源的总量除以每个任务对该资源的需求量,所得结果就是线程池大小的上限。

Amdahl定律:

并发后的加速比 <=1/(F+(1-F)/N)

其中F为串行计算部分的百分比,NCPU

 

41.线程池的基本大小即没有任务执行时线程池的大小,只有在工作队列已满才会创建超出这个数量的线程。如果某个线程超过了存活时间,该线程被标记为可回收,如果同时当前线程池大小超过基本大小,该线程将被终止。

 

42.对于没有使用SynchronousQueue作为工作队列的线程池(newCacheThreadPool默认使用该队列),如果线程池中的线程数量等于基本大小,仅当队列已满时才会创建新的线程,因此如果设置基本大小为0且队列未满,任务达到后先进入队列,由于此时线程数为0因此不会执行任务,只有待队列满时才会真正执行任务。

 

43.基本任务排队方法及被何种线程池采用:

          a:无界队列:如无界LinkedBlockingQueue(FIFO),newFixedThreadPoolnewSingleThreadExecutor默认使用此队列,此队列的好处是能让所有线程池中的线程保持忙碌状态,缺点是一旦生产大于消费,队列无限制增大耗尽内存。

          b:有界队列:如ArrayBlockingQueue(FIFO),有界的LinkedBlockingQueue(FIFO)PriorityBlockingQueue(任务按自然顺序或实现Comparable排序)。当有界队列满后会根据饱和策略处理.

          c:同步移交(Synchronous Handoff)SynchronousQueue,必须有空闲线程(或还能创建新线程)等待接受时才可以,否则拒绝。一般只用在线程池无界或可以拒绝任务时,如在newCachedThreadPool(由于运用了此队列,因此它能比固定大小的线程池提供更好的排队性能,特别是Java6优化了非阻塞算法,因此只要不是受特殊资源限制,都建议用newCachedThreadPool作为默认的选择).

 

44.四种饱和策略(ThreadPoolExecutor可以调用setRejectedExecutionHandler来设置)

          a)中止策略:默认策略,抛出未检查的RejectedExecutionException.

          b)抛弃策略:放弃该新任务

          c)抛弃最旧的策略:根据FIFO,最旧的就是下一个将被执行的任务被抛弃以尝试提交新的任务。因此一般该策略不和FIFO一起用。

          d)调用者运行策略:即在调用了execute的主线程中执行,这样在一段时间内无暇再接受新的任务,新到达的请求停留在TCP层,如果持续过载,TCP层也会抛弃请求的,从而实现一种平缓的性能降低。其过程为:线程池--->工作队列---->应用程序--->TCP--->客户端。

 

45.可以通过实现ThreadFactory接口以及继承Thread类来自己定义如何产生新线程.比如可以加入日志,调用setUncaughExceptionHandler来设置该线程由于未捕获到异常而突然终止时调用的处理程序。

 

46.当然也可以调用Executors中的unconfigurableExecutorService封装一个具体的ExecutorService以隐藏对ThreadPoolExecutor的配置。另外如果继承ThreadPoolExecutor,可以更灵活的扩张线程池,主要有以下几个方法:

          a)beforeExecute:任务执行前调用,如果抛出异常,则任务不被执行,此任务结束

          b)afterExecute:只要任务在完成后不是带有一个Error,不管是正常返回还是抛出一个异常都会被执行。

          c)terminated:所有任务已经完成且所有工作者线程关闭后调用,可以用来释放期生命期分配的资源及发送通知,记录日志,收集统计信息等。

 

47.多线程很重要的一个应用是将多个迭代之间彼此独立,而且每个迭代操作执行的工作量比管理一个新任务开销大的串行计算转换为并行,多个线程同时独立计算各部分结果,当某一个线程得到结果时,通过设置一个公共同步变量或synchronized方法来通知不需再产生新的任务并可以通知其他任务线程适当结束自己。当然,也要考虑实在没有结果的情况,为避免永远等待结果,可以设置一个同步计数器,每个处理任务结束时在finally块先将计数器减一并查看当前剩余工作线程是否为0,为0则表示无结果,可以设置一个空结果。

 

并发的不良后果-活跃性故障

48.活跃性故障最常见的是锁顺序死锁,即两个线程试图以不同的顺序来获得相同的锁,或者多个线程相互持有彼此正在等待的锁而又不释放自己已持有的锁时会发生死锁情况。包括以下几种情况;1:不同方法中锁的顺序不一样,A方法先锁Key1,再锁Key2,而B方法先锁Key2,再锁Key1. 2:方法中对传入的参数加锁,如果两个参数类型相同,当不同地方的调用这个方法传入的参数顺序相反,如fromAccounttoAccount 3:协作对象间加锁方法相互调用出现隐秘的死锁。解决死锁有以下几种方法;1:通过对象的hash值判断锁顺序从而保证锁顺序一致 2:增加加时锁 3减小同步加锁的代码块,将方法级加锁改为代码块级加锁,尽量通过良好的线程封装开放调用。4.使用支持定时的锁。可通过死锁时转储的信息来分析死锁发生的原因。

 

49.活跃性故障另外两个不良后果,一个是线程饥饿,即由于线程优先级的调整,导致有的线程始终无法得到cpu执行,另一个是活锁,即多个相互协作的线程都对彼此进行响应从而修改各自的状态,并使得任何一方都无法继续。比如过度的错误恢复代码。一般在并发应用中,通过随机等待长度的时间和回退可以避免之。

 

Java并发编程实战基础概要

Java并发总结-全景图

Java并发编程实战总结

Java并发编程实战总结

Java并发编程实战 04死锁了怎么办?

Java并发编程实战 04死锁了怎么办?