EffectiveJava-9-并发

同步访问共享的可变数据

关键字synchronized可以保证同一时刻只有一个线程可以执行某个方法或某个代码块;

同步不仅可以阻止线程看到对象处于不一致的状态中(互斥),还可以保证进入同步方法或同步代码块的每个线程, 都看到由同一个锁保护的之前所有的修改结果;

java语言规范保证读或者写一个变量是原子的(除非这个变量的类型是long或double);但是它不保证一个线程写入的值对于另一个线程将是可见的,所以, 为了在线程间进行可靠的通信,也为了互斥访问,同步是有必要的,这归因于java语言规范中的内存模型, 它规定了一个线程所做的变化何时及如何变成对其他线程可见;

(要阻止一个线程妨碍另一个线程,建议让第一个线程轮询一个Boolean值(Boolean的读写操作都是原子的))

看下面这个例子,对共享的可变数据的访问不同步,即使这个变量是原子可读写的

使用volatile务必要小心

问题在于,增量操作符++不是原子的,它在getNextNum中执行两项操作,首先它读取值,然后写回一个新值(原值+1);

如果第二个线程在第一个线程读取旧值和写回新值期间读取这个域,第二个线程就会与第一个线程一起取到同一个值,这就是安全性失败;

改良如下:

或者使用类AtomicLong:

避免本条中所讨论问题的最佳办法是不共享可变的数据;

即要么共享不可变数据,要么不共享,将可变数据限制在单个线程中;

安全发布对象引用的方法

1. 将它保存在静态域中,作为类初始化的一部分;

2. 将它保存在volatile域,final域或者通过正常锁定访问的域中;

3. 将它放到并发的集合中;

总结:多个线程共享可变数据时,每个读写数据的线程都必须执行同步,否则可能造成活性失败和安全性失败,很难调试;如果只需要线程间的交互通信,而不需要互斥,volatile修饰符是一种可以接受的同步形式,但要正确的使用它需要一些技巧;

避免过度同步

过度同步可能导致性能降低,死锁,甚至不确定的行为;

在一个被同步的区域内部,不要调用设计成要被覆盖的方法或者由客户端以函数对象的形式提供的方法;

(同步代码中,永远不要放弃对客户端的控制;不知道会做什么事,也无法控制它)

应该在同步区域中做尽可能少的工作,尤其是耗时操作;

多核时代,过度同步的实际成本并不是指获取锁所花费的cpu时间,而是指失去了并行的机会,和确保一致性导致的延迟;过度同步另一个潜在开销在于,会限制VM优化代码执行的能力;

一个可变类要并发使用,应尽量让客户在必要的时候从外部同步,反例如StringBuffer实例几乎总是用于单个线程,它执行的却是内部同步, 为此基本都由StringBuilder代替,所以,当你不确定的时候,就不要同步你的泪,而是建立文档,注明它不是线程安全的;

如果你在内部同步了类,就可以使用不同的方法来提高并发性,如拆分锁,分离锁,非阻塞并发控制等;

如果方法修改了静态域,那么你也必须同步对这个域的访问,即使它往往只用于单个线程,上面的testVolatile方法就是一个例子;

executor和task优先于线程

线程池:

好处

1. 重用线程池中的线程,避免线程的创建和销毁所带来的性能开销;

2. 能有效控制线程池中的最大并发数,避免大量线程之间相互抢占系统资源而导致的阻塞现象;

3. 能够对线程进行简单的管理,提供如定时执行,制定间隔循环等功能;

起源

Android中的线程池概念源于java中的 Executor,具体实现是 ThreadPoolExecutor;
分类

主要分为4类,可通过Executors(注意是s)的工厂方法获得;

1. Executors.newFixedThreadPool():
只有核心线程且没有超时机制,线程不会回收,可以快速相应外界请求,且任务队列大小没有限制;

2. Executors.newCachedThreadPool():
只有非核心线程,最大线程数为Integer.MAX_VALUE,超时时长60s,且任务队列无法存储元素,适合执行大量耗时较少的任务;

3. Executors.newScheduledThreadPool():
核心线程数固定,非核心数不限,且非核心线程闲置时会立即回收,主要用于执行定时任务和具有固定周期的重复任务;

4. Executors.newSingleThreadExecutor():
只有一个核心线程,统一所有的外界任务到一个线程中,顺序执行,这使得这些任务之间不需要处理线程同步的问题;

重要参数:

corePoolSize:

线程池的核心线程数,默认情况下,核心线程会在线程池中一直存活,即使他们处于闲置状态;但是若将allowsCoreThreadTimeOut=true,那么闲置的核心线程在等待新任务时会有超时策略,时长由keepAliveTime 控制,超时后核心线程会被终止;

maximumPoolSize:

线程池所能容纳的最大线程数,当活动线程数达到这个数值后,后续的新任务将被阻塞;

执行任务的大致规则:

1. 若线程池中的线程数量未达到核心线程数,则直接启动一个核心线程执行任务;
2. 若已达到,则任务被插入到任务队列中排队等待执行;
3. 若无法插入到任务队列,往往是由于任务队列已满,若此时线程数量未达到最大线程数,则立即启动一个非核心线程来执行任务;
4. 若已达到,则拒绝执行此任务;
不仅应该尽量不要编写自己的工作队列,而且还应该尽量不直接使用线程;
ScheduledThreadPoolExecutor可以代替timer,虽然timer使用起来更容易,但是被调度的线程池executor更加灵活;
timer只用一个线程来执行任务,这在面对长期运行的任务时,会影响到定时的准确性,如果timer唯一的线程抛出未被捕获的异常, timer就会停止执行;

ExecutorService中submit和execute的区别:

1、接收的参数不一样

2、submit有返回值,而execute没有

3、submit方便Exception处理:如果你在你的task里会抛出checked或者unchecked exception,

而你又希望外面的调用者能够感知这些exception并做出及时的处理,

那么就需要用到submit,通过捕获Future.get抛出的异常;

shutdown和shutdownNow区别

1. shutdown():

不能接受新的submit;

并没有任何的interrupt操作,会等待线程池中所有线程(执行中的以及排队的)执行完毕;

可以理解为是个标识性质的方法,标识这程序有意愿在此刻终止线程池的后续操作;

2. shutdownNow():

会尝试interrupt线程池中正在执行的线程;

等待执行的线程也会被取消;

但是并不能保证一定能成功的interrupt线程池中的线程;

会返回并未终止的线程列表List<Runnable>

shutdownNow()方法比shutdown()强硬了很多,不仅取消了排队的线程而且确实尝试终止当前正在执行的线程;

并发工具优先于wait和notify

有了更高级的并发工具,几乎没有理由再使用wait和notify;
java.util.concurrent中的工具分三类
1. 上一节中介绍的Executor Framework;
2. 并发集合(Concurrent Collection):
为标准的集合接口(list,map,queue等)提供了高性能的并发实现,为了提供并发性,这些实现在内部自己管理同步;

应该优先使用ConcurrentHashMap,而不是Collections.synchronizedMap()或者Hashtable ;
只要用并发map替换老式的同步map就可以极大地提升并发程序的性能;

如下例中用ConcurrentMap模拟String.intern:

有些集合接口已经通过阻塞操作进行了扩展,它们会一直等待(阻塞)到可以成功执行为止,例如BlockingQueue扩展了Queue接口, 这样就允许将阻塞队列用于工作对了,也称生产者-消费者队列;大多数ExecutorService实现都使用BlockingQueue;

3. 同步器(Synchronizer) :
是一些使线程能够等待另一个线程的对象,允许他们协调动作,如常用的同步器CountDownLatch和Semaphone,和不常用的CyclicBarrier和Exchanger;
CountDownLatch:倒计数锁存器,是一次性的障碍,允许一个或多个线程等待一个或多个其他线程来做某些事情;
如下例:

虽然应始终优先使用并发工具,但还可能维护wait和notify的遗留代码;
wait被用来使线程等待某个条件,必须用在同步区域内被调用,下面是使用wait的标准模式 :

始终应该使用wait循环模式来调用wait方法,永远不要在循环之外调用wait方法, 循环会在等待之前(当条件已成立时跳过等待,确保活性) 和之后(条件不成立则继续等待,确保安全性)测试条件;
避免在公有可访问对象上的意外或恶意的唤醒;
对于应该使用notify还是notifyAll,一种常见说法是应该总是使用notifyAll,因为它总会产生正确结果, 因为它可以保证将会唤醒所有需要被唤醒的线程,可能也会唤醒其他一些线程,但者不影响程序的正确性,因为这些 线程醒来后,会检查他们正在等待的条件,若条件不满足则继续等待;
避免来自不相关线程的意外或恶意的等待;

线程安全性的文档化

一个类为了可被多个线程安全的使用(实例或静态方法),必须在文档中清楚地说明它所支持的线程安全性级别;
常见安全性级别:

1. 不可变的:实例不可变,所以,不需要外部的同步,如String,Long,BigInteger;

2. 无条件的线程安全:实例可变,但有足够的内部同步,所以,它的实例可以被并发使用,无需任何外部同步,如Random,ConcurrentHashMap;

3. 有条件的线程安全:除了有些方法为进行安全的并发而使用外部同步外,与无条件的线程安全相同, 如Collections.synchronized包装返回的集合,他们的迭代器要求外部同步;

4. 非线程安全:实例可变,为了并发的使用它们,客户必须利用自己选择的外部同步包围每个方法调用(或调用序列), 如通用的集合实现ArrayList,HashMap;

5. 线程对立的:这个类不能安全的被多个线程并发使用,即使所有方法调用都被外部同步包围, 线程对立根源通常在于,没有同步的修改静态数据, 没有人会刻意编写这样的类,往往是没有考虑到并发, 如System.runFinalizersOnExit方法就是线程对立的(已经被废除);
例如下面Collections.synchronizedMap的文档:

除非从返回类型来看已经很明显,否则静态工厂必须在文档中说明被返回对象的线程安全性,如上述所示;

为了避免拒绝服务攻击(只需超时的保持公有可访问锁即可),应该使用一个私有锁对象来代替同步的方法(隐含着一个共有可访问锁);
因为这个私有锁对象不能被客户端程序所访问,所以他们不可能妨碍对象的同步;

(把锁对象封装在它所同步的对象中)

注意
私有锁对象模式只能用在无条件的线程安全类上;
私有锁对象模式特别适用于那些专门为继承而设计的类;

慎用延迟初始化

延迟初始化
延迟到需要域的值时才将它初始化;
(用不到就永远不被初始化)

建议
除非绝对必要,否则就不要这么做,就像一把双刃剑,降低了初始化类或创建实例的开销, 却增加了访问被延迟初始化的域的开销;(实际上降低了性能)

是否使用
测量类在用和不用延迟初始化时的性能差别;
多线程使用延迟初始化时,同步很重要;
(如使用synchronized同步访问方法)
如果出于性能考虑而需要对静态域使用延迟初始化,就使用lazy initialization holder class(静态内部类)模式, 这种模式保证了类要到被用到的时候才被初始化;
(优势:不需要同步,并且只执行一个域访问)
如果出于性能考虑而需要对实例域使用延迟初始化,就使用双重检查模式(double check idiom,也叫双重校验锁), 避免了在域被初始化之后访问这个域时的锁定开销;

不要依赖于线程调度器

当有多个线程可以运行时,由线程调度器决定哪些线程将会运行,以及运行多久;

任何依赖线程调度器来达到正确性或性能要求的程序,很有可能都是不可移植的;

要编写健壮的,响应良好的,可移植的多线程应用程序,最好的办法是确保可运行线程的平均数量 不明显多于处理器的数量(让每个线程做些有意义的工作,然后等待更多有意义的工作;如果线程没有在做有意义的工作就不应该运行;)。
线程不应该一直处于忙--等的状态,即反复的检查一个共享对象,以等待某些事情发生, 会极大地增加处理器负担,降低统一机器上其他进程可以完成的有用工作量;

线程优先级是java平台上最不可移植的特性了,通过调整线程优先级来改善应用程序的响应能力, 并发不合理,却是不必要,也是不可移植的;

(线程优先级可以用来提高一个已经能正常工作的程序的服务质量,却不能用来修正一个原本并不能工作的程序)

java语言规范中,Thread.yield根本不做实质性的工作,只是将控制权返回给它的调用者;(即,不要依赖Thread.yield或线程优先级,这些设施仅仅对调度器有些暗示)

避免使用线程组(Thread Group)

线程组的初衷是作为一种隔离applet(小程序)的机制,当然是出于安全的考虑,但它们从没有真正履行这个承诺;

(没有提供所提及的任何安全功能)(线程组已经过时)(直接忽略掉它们)

允许你同时把Thread的某些基本功能应用到一组线程上;

我是今阳,如果想要进阶和了解更多的干货,欢迎关注公众号”今阳说“接收我的最新文章

猜你喜欢

转载自blog.csdn.net/o118abcdef/article/details/112857154