学习Java的同学注意了!!!
学习过程中遇到什么问题或者想获取学习资源的话,欢迎加入Java学习交流群,群号码:184625948【长按复制】 我们一起学Java!
Java并发知识总结,参考<<并发编程实战>>,不贴代码…
1. 线程安全性
线程安全性核心概念是正确性。某个类的行为与其规范完全一致。多个线程同时操作共享的变量,造成线程安全性问题。
编写线程安全性代码的三种方法:1. 不在线程之间共享该状态变量 2. 将状态变量修改为不可变的变量 3. 在访问状态变量时使用同步
Java同步机制工具:synchronized, volatile类型变量, 显示锁(Explicit Lock ), 原子变量
原子性:
不可再分的操作。例如:读原子操作,写原子操作.
改变变量的值,非原子操作,因为涉及读.
写线程安全需要考虑的因素:
-
对象状态 - 什么叫有状态和无状态?无状态对象肯定是线性安全的
-
复合操作 - 操作有多个步骤完成的操作 (例如, 先检测后执变量的操作都分类三步 : 读取 - 修改 - 写入. )
-
竞态条件
先检测后执行
延迟初始化竞态条件(单例, 调用方法时才返回对象)
加锁机制
遇到问题 - 可以保证每个变量都是线程安全的,但是如果一个方法中同时有多个变量,必须保证变量同步更新才算线程安全。多个变量时需要加同一个锁,保证多个变量同时更新 。
通常认为只有写入的时候才需要锁,但如果读取的时候值值不能确保是否有其他现在正在修改或者以修改,同样会遇到问题。
内置锁:java提供了一种内置的锁机制来支持原子性:同步代码块.
重入:某个线程试图获得一个已经由它自己持有的锁,可重入意味着成功,获取锁的粒度是线程,而不是调用.
活跃性与性能:
活跃性问题?
例如: 线程A等待线程B释放其持有的资源
性能问题?
例如: 线程切换过于频繁,CPU在线程调度上花费资源过多
2. 对象的共享
可见性
什么是可见性?
Java线程安全需要防止某个线程正在使用对象状态而另一个线程在同时修改该状态,而且需要确保当一个线程修改了对象的状态后,其他线程可以看到发生的状态变化。 后者就是可见性的描述即多线程可以实时获取其他线程修改后的状态。
-
失效数据
可见性出现问题就是其他线程没有获取到修改后的状态,更直观的描述就是其他线程获取到的数据是失效数据。
-
非原子64位操作
-
加锁与可见性
例如在一个变量的读取与+1上添加一把锁,锁保证了其他线程获取到此变量都是+1后的值,所以可以保证可见性。
-
Volatile变量
线程对共享变量的修改,对其他线程可见
满足以下条件,可以使用Volatitle完成并发:
-
对变量的写入操作不依赖变量的当前值,或者你能确保只有单个线程更新变量的值。
-
该变量不会与其他状态变量一起纳入不变形条件中。
-
在访问变量时不需要加锁。
发布 、逸出
发布一个对象的意思是指,使对象能够在当期作用域之外的代码中使用。简单的可以理解为其他地方获取到当前类的对象,这种情况就是发布当前类。
Java多线程不仅要确保当前类是线程安全的,而且需要保证使用当前类对象的所有地方都要保证线程安全性。
某个不应该发布的对象被发布,就称为”逸出”.
封闭
-
线程封闭 - 把共享的数据,仅在线程中使用,不共享.例如java的ThreadLocal类
Ad-hoc线程封闭 – 维护线程封闭性的职责完全由程序实现承担.(很脆弱)
线程封闭 - 通常将特定子系统实现为一个单线程子系统
-
栈封闭
线程封闭的特例, 例如基本类型的局部变量.
ThreadLocal - 通常防止可变的单实例对象 或 全局变量进行共享.
不可变
final对象,本身不可改变,但是final中的变量却可以改变
安全发布方式:
-
静态初始化函数中初始化一个对象引用
-
将对象的引用保存到volatile类型的域或者AtomicReference对象中
-
将对象的引用保存到某个正确的构造对象的final类型域中
-
将对象的引用保存到一个由锁保护的域中
3. 对象的组合
设计线程安全的类
在设计线程安全类的过程中,需要包含以下三个基本要素:
. 找出构成对象状态的所有变量。
. 找出约束状态变量的不变性条件。
. 建立对象状态的并发访问管理策略。
分析对象的状态,首先从对象的域开始。 变量按作用域划分:
. 全局变量
. 局部变量
. 方法行参
. 异常处理参数
-
收集同步需求
如果不了解对象的不变性条件与后验条件,那么就不能确保线程安全性。要满足在状态变量的有效值或状态转换上的各种约束条件,就需要借助原子性和封装性。
说的更简略些是Java线程安全都是因为共享变量,共享变量后会因为多个线程同时修改导致不正确的问题,所以收集一共有多少处会涉及到这些需要同步的变量,只有收集说有可能出问题的因素基于此之上保证所有元素线程安全也才能保证程序是线程安全的。
-
依赖状态的操作
先验条件是值满足某个条件之后才能进行处理。例如:首先判断一个队列是否为空,如果为空….如果不为空….其中判断队列是否为空就是先验条件。
如果在某个操作中包含有基于状态的先验条件,那么这个操作就称为依赖状态的操作。满足可见性就可以?
-
状态的所有权
单独一个基本对象比较保证其安全性,但是如果是包含对象的集合(容器类 例如:ArrayList),容器类通常表现出一种“所有权分离”的形式。
即使用线程安全的容器类(Collections.synchronizedList(List )),也只能保证容器相关的操作是线程安全的,如果发布了可变对象的引用,就不会拥有独占的控制权。(非线程安全)
实例封装
将数据封装在对象内部,可以将数据的访问限制在对象的方法上,从而更容易确保线程在访问数据时总能持有正确的锁。
封闭机制更易于构造线程安全的类,因为当封闭类的状态时,在分析类的线程安全性时就无须检查整个程序。
即使封闭能保证对象内所有处理都是现成安全的,但是还需要注意当对象发布后还是可能出现问题,例如HashSet
除保证Persion是线程安全外,还需要保证使用它的Set集合是线程安全的。
例子 对象中仅有一个变量,保证此变量线程安全。在方法上使用synchronized
Java监听器模式
遵循java监听器模式的对象把对象的所有可变状态都封装起来,并由对象自己的内置锁来保护.
线程安全性的委托
例如线程安全性委托给ConcurrentHashMap,但是注意保证map中的变量是不可变的或者是有同步措施的.
在现有的线程安全类中添加功能
-
继承自当前集合类,加锁使添加的方法保证安全性,但是这样比较脆弱。
-
使用组合方式
将同步策略文档化
4. 基础构建模块
同步容器类
包括Vector和Hashtable.这些类实现线程安全的方式是:将它们的状态封装起来,并对每个公有方法都进行同步,使得每次只有一个线程能访问容器的状态.
线程容器类都是线程安全的,但是当在其上进行复合操作则需要而外加锁保护其安全性。复合操作包括:迭代, 跳转, 条件运算.
并发容器
并发容器来代替同步容器,可以极大地提高伸缩性并降低风险。
ConcurrentHashMap,CopyOnWriteArrayList,Queue,BlockingQueue,ConcurrentLinkedQueue,PriorityQueue,ConcurrentSkipListMap,ConcurrentSkipListSet等并发容器,详见Java并发包。
-
ConcurrentHashMap:用于替代同步且基于散列的Map。优点:分段锁,在并发访问环境下实现更高的吞吐量,在单线程环境中只损失非常小的性能。缺点:不支持客户端加锁,无法创建新的原子操作。
-
CopyOnWriteArrayList:用于替代同步List,某些情况下提供更好的并发性能,并且在迭代期间不需要对容器进行加锁或复制.优点:写入时复制,线程安全性在于只要正确地发布一个事实不可变对象,那在访问该对象时就不再需要进一步的同步。在每次修改时,都会创建并重新发布一个新的容器副本,从而实现可变性。缺点:修改容器时需要复制底层数组,有一定的开销。仅当迭代操作远远多于修改操作时使用,比如事件通知系统。
阻塞队列与生产者-消费者模式
生产者与消费者模式:基于阻塞队列构建的生产者与消费者模式中,当数据生成,生产者把数据放入队列,当消费者处理数据时,从队列中获取数据集.
阻塞队列BlockingQueue有多种实现,LinkedBlockingQueue和ArrayBlockingQueue是FIFO队列,分别对应LinkedLIst和Arraylist.PriorityBlockingQueue是一个按优先级排序的队列.
还有SynchronousQueue,它不会为队列中的元素维护存储空间,生产者直接反馈给消费者.
另外,双端队列:Deque,BlockingDeque,适用于工作密取.每个消费者都拥有自己的双端队列,工作者线程需要访问另一个队列时,它会从队列的尾部而不是从头部获取工作.
阻塞和中断
Thread提供了interrupt方法,用于中断线程或者查询线程是否已经中断.
中断是一种协作机制.
当在代码中抛出一个受检查异常InterruptedException,最好是捕获后恢复中断 Thread.currentThread().interrupt()
.
同步工具类
-
闭锁:一种同步工具类,可以延迟线程的进度直到其到达终止状态。闭锁就像一扇门,可以用来确保某些活动直到其他活动都完成后才能继续执行。二元闭锁(包括两个状态)可以用来表示“资源R已经被初始化”,而所有需要R的操作都必须先在这个闭锁上等待。 CountDownLatch是一种灵活的闭锁实现。如何实现的????
-
FutureTask:也可以用作闭锁,实现了Future语义,表示一种抽象的可生成结果的计算。
-
信号量:计数信号量用来控制同时访问某个特定资源的操作数量,或同时执行某个指定操作的数量。计数信号量还可以用来实现某种资源池,或者对容器施加边界。Semaphore,permit许可,二值信号量,互斥体mutex,不可重入。
-
栅栏:闭锁可以用来启动一组相关的操作,或者等待一组相关的操作结果,但闭锁是一次性对象,一旦进入终止状态,就不能被重置。栅栏类似于闭锁,它能阻塞一组线程直到某个事件发生。栅栏与闭锁的关键区别在于,所有线程必须同时到达栅栏位置,才能继续执行。闭锁用于等待事件,而栅栏用于等待其他线程。CyclicBarrier可以是一定数量的参与方反复地在栅栏位置汇集,它在并行迭代算法中非常有用。n-body粒子模拟系统,Conwy生命游戏。Exchanger是另一种形式的栅栏,两方栅栏,各方在栅栏位置上交换数据。
构建高效可伸缩缓存
这个真心写得好…
首先考虑采用HashMap–>为了线程安全,使用ConcurrentHashMap.但是可能导致很多线程在计算同样的值–>考虑阻塞方法,使用基于FutureTask的ConcurrentHashMap,但仍然不是原子性的–>使用ConcurrentHashMap中的putifAbsent()–>继续解决缓存污染问题,当缓存结果失效时移除.解决缓存逾期,缓存清理等等问题.
5. 任务执行
Executor框架
Executor基于生产者-消费者模式,提交任务的操作相当于生产者,执行任务的线程则相当于消费者。
-
Executors 返回 ExecutorService
-
ExecutorService方法submit、execute
-
ExecutorService.submit 返回 Future
Executors内置线程池
-
newFixedThreadPool 将创建一个固定长度的线程池,每当提交一个任务时就创建一个线程,知道达到线程池的最大数量,这时线程池的规模将不再变化(如果某个线程由于发生了未预期的Exception而结束,那么线程池会补充一个新的线程。)
-
newCachedThreadPool 将创建一个可缓存的线程池,如果线程池的当前规模超过了处理需求时,那么将回收空闲的线程,而当需求增加时,则可以添加新的线程,线程池的规模不存在任何限制。
-
newSingleThreadExecutor 将会创建一个单线程的Executor,它创建单个工作者线程来执行任务,如果这个线程异常结束,会创建另一个线程来替代。newSingleThreadExecutor能确保依照任务在队列中的顺序来串行执行(例如FIFO、LIFO、优先级)
-
newScheduledThreadPool 创建了一个固定长度的线程池,而且以延迟或定时的方式来执行任务,类似于Timer。
Executor的生命周期
以上四个方法都会返回ExecutorService,ExecutorService的生命周期有3种状态:运行、关闭和已终止。
shutdown 将执行平缓的关闭过程:不再接受新的任务,同时等待已经提交的任务执行完成—包括那些还未开始执行的任务。
shutdownNow 将执行粗暴的关闭过程:它将尝试取消所有运行中的任务,并且不再启动队列中尚未开始执行的任务。
携带结果的任务Callable与Futrue
Executor框架使用Runnable作为基本的任务表示形式,但如果我们需要返回值,需要使用Callable.
Future表示一个任务的生命周期,ExecutorService所有submit方法都将返回一个Future.
FutureTask实现了Future以及Runnable,同时也可以包装callable.
综上,如果我们不需要返回值,可以用Runnable.需要返回值,使用callable,或者包装了callable的Futuretask.返回值用Future接收,或者直接用FutureTask.
在异构任务并行化中存在的局限
如果一个任务是读取IO资源,可以使用多个线程去同时读取,但是效率上限可能出在IO上,即使开启再多线程读取总速度也不可能超出IO读取速度上限。
开启多个线程本身也会调高编程难度,同时开启多个线程也会造成资源消耗。
多线程提高效率很多时候并不是增加一个线程效率提高一倍,可能提高的效率微乎其微。
Executor与BlockingQueue
如果想提交一组计算任务,并且希望在计算完成后获得结果,可以使用BlockingQueue保存每个任务的Future。
为任务设置时限
//等待3秒,超时后会抛TimeoutException
future.get(3, TimeUnit.SECONDS);
ExecutorService.invokeAll()
执行给定的任务,当所有任务完成时,返回保持任务状态和结果的 Future 列表。返回列表的所有元素的 Future.isDone() 为 true。注意,可以正常地或通过抛出异常来终止已完成 任务。如果正在进行此操作时修改了给定的 collection,则此方法的结果是不确定的。
6. 取消与关闭
任务取消
取消操作的原因:
. 用户请求取消
. 有时间限制的操作
. 应用程序事件
. 错误
. 关闭
结束任务的四种方式:
-
run方法执行结束
-
使用请求关闭标记(例如boolean开关)
-
使用中断机制
-
使用Future退出方法
-
使用请求关闭标记
当执行到并满足条件时使用return退出run方法
变量需要volatile确保变量多线程环境下的可见性。
-例子待填充,没有执行到判断条件就不会退出,所以不是立即退出的办法。
-
使用中断机制
优点是相对“请求关闭标记”相应更快一些,但也不是立即关闭线程。
void interrupt()
中断线程。boolean interrupted()
测试当前线程是否已经中断。boolean isInterrupted()
测试线程是否已经中断。InterruptedException异常
-
使用Future退出方法
boolean cancel(boolean mayInterruptIfRunning)
试图取消对此任务的执行。boolean isCancelled()
如果在任务正常完成前将其取消,则返回 true。
处理不可中断的阻塞:
-
java.io包中的同步Socket I/O
-
java.io包中的同步I/O
-
Selector的异步I/O
-
获取某个锁
采用newTaskFor来封装费标准的取消
停止基于线程的服务
之前的任务取消,主要是涉及如何关闭单个线程并且都是由创建单个线程的对象来进行关闭操作,但是如果线程不是由对象自己而是由线程池统一创建的线程该如何处理呢?
-
使用线程的对象进行关闭 - 当前即使不在对象中创建线程而由线程池创建,这个对象依然可以关闭线程,这点一定要相信程序员的破坏能力,只是使用第2种方式更符合封装原则。
-
使用线程池统一管理 - 如果是使用ExecutorService创建就交由其进行关闭操作。
使用线程池统一管理(关闭ExecutorService)
void shutdown()
启动一次顺序关闭,执行以前提交的任务,但不接受新任务。如果已经关闭,则调用没有其他作用。
– 安全关闭方式。
List
试图停止所有正在执行的活动任务,暂停处理正在等待的任务,并返回等待执行的任务列表。
无法保证能够停止正在处理的活动执行任务,但是会尽力尝试。例如,通过 Thread.interrupt() 来取消典型的实现,所以任何任务无法响应中断都可能永远无法终止。
– shutdownNow方法的局限性,强制关闭方式。
boolean isShutdown()
boolean isShutdown()如果此执行程序已关闭,则返回 true。
“毒丸”对象
毒丸是指放在队列上的一个对象,含义是”当得到这个对象,就立即停止”.只有在生产者消费者的数量都已知的情况下,才可以使用“毒丸”对象。
处理非正常的线程终止
Thread.UncaughtExceptionHandler全局的捕获的异常处理,通常在应用中用于异常的统计,收集到这些统计后可以对应用进行异常修复。
JVM关闭
-
关闭钩子
Runtime.getRuntime().addShutdownHook(new Thread()) ;
void addShutdownHook(Thread hook)
注册新的虚拟机来关闭钩子。
-
守护线程
希望创建一个线程来执行一些辅助工作,但又不希望这个线程阻碍JVM的关闭,可以使用守护线程。
-
终结器
避免使用终结器finalize
7. java线程池
线程池的作用:线程池作用就是限制系统中执行线程的数量。
根据系统的环境情况,可以自动或手动设置线程数量,达到运行的最佳效果;少了浪费了系统资源,多了造成系统拥挤效率不高。用线程池控制线程数量,其他线程排队等候。一个任务执行完毕,再从队列的中取最前面的任务开始执行。若队列中没有等待进程,线程池的这一资源处于等待。当一个新任务需要运行时,如果线程池中有等待的工作线程,就可以开始运行了;否则进入等待队列。
为什么要用线程池:
1.减少了创建和销毁线程的次数,每个工作线程都可以被重复利用,可执行多个任务。
2.可以根据系统的承受能力,调整线程池中工作线线程的数目,防止因为消耗过多的内存,而把服务器累趴下(每个线程需要大约1MB内存,线程开的越多,消耗的内存也就越大,最后死机)。
Java里面线程池的顶级接口是Executor,但是严格意义上讲Executor并不是一个线程池,而只是一个执行线程的工具。真正的线程池接口是ExecutorService。
比较重要的几个类:
ExecutorService:真正的线程池接口。
ScheduledExecutorService:能和Timer/TimerTask类似,解决那些需要任务重复执行的问题。
ThreadPoolExecutor:ExecutorService的默认实现。
ScheduledThreadPoolExecutor:继承ThreadPoolExecutor的ScheduledExecutorService接口实现,周期性任务调度的类实现。
在任务与执行策略之间的隐性解耦
有些类型的任务需要明确地指定执行策略,包括:
. 依赖性任务。依赖关系对执行策略造成约束,需要注意活跃性问题。要求线程池足够大,确保任务都能放入。
. 使用线程封闭机制的任务。需要串行执行。
. 对响应时间敏感的任务。
. 使用ThreadLocal的任务。
-
线程饥饿死锁
线程池中如果所有正在执行任务的线程都由于等待其他仍处于工作队列中的任务而阻塞,这种现象称为线程饥饿死锁。
-
运行时间较长的任务
Java提供了限时版本与无限时版本。例如Thread.join、BlockingQueue.put、CutDownLatch.await、Selector.select
设置线程池的大小
要正确地设置线程池的大小,你必须估算出任务的等待时间与计算时间的比值。这种估算不需要很精确,并且可以通过一些分析或监控工具来获得。
公式定义:
int N_CPUS = Runtime.getRuntime().availableProcessors();
CPU并不是唯一影响线程池大小的资源,还包括内存、文件句柄、套接字句柄和数据库连接等。计算每个任务对该资源的需求量,然后用该资源的可用总量除以每个任务的需求量,所得结果就是线程池大小的上限。
配置ThreadPoolExecutor
API文档中对构造函数的描述:
public ThreadPoolExecutor(int corePoolSize, int maximumPoolSize, long keepAliveTime,
TimeUnit unit,
BlockingQueue
RejectedExecutionHandler handler)
corePoolSize :池中所保存的线程数,包括空闲线程。
maximumPoolSize:池中允许的最大线程数。
keepAliveTime: 当线程数大于核心时,此为终止前多余的空闲线程等待新任务的最长时间。
unit:keepAliveTime 参数的时间单位。
workQueue :执行前用于保持任务的队列。此队列仅保持由 execute方法提交的 Runnable任务。
threadFactory:执行程序创建新线程时使用的工厂。
handler :由于超出线程范围和队列容量而使执行被阻塞时所使用的处理程序。
ThreadPoolExecutor是Executors类的底层实现。
-
线程池的创建与销毁
可以查看Executors几个方法源码来辅助理解ThreadPoolExecutor参数的配置。
-
管理队列任务
ThreadPoolExecutor允许提供一个BlockingQueue来保存等待执行的任务。基本的任务排队方法有3种:无界队列(无界的LinkedBlockingQueue).
有界队列(ArrayBlockingQueue,有界的LinkedBlockingQueue…).
同步移交(SynchronousQueue)
-
饱和策略
有界队列已经填满或者向关闭的Executor提交任务时需要考虑饱和策略。
ThreadPoolExecutor.setRejectedExecutionHandler
public void setRejectedExecutionHandler(RejectedExecutionHandler handler)
设置用于未执行任务的新处理程序。java.util.concurrent.RejectedExecutionHandler 所有已知实现类:
AbortPolicy是默认饱和策略
-
ThreadPoolExecutor.AbortPolicy,这种策略直接抛出异常,丢弃任务.处理程序遭到拒绝将抛出运行时RejectedExecutionException .
-
ThreadPoolExecutor.CallerRunsPolicy,线程调用运行该任务的 execute 本身。此策略提供简单的反馈控制机制,能够减缓新任务的提交速度。这个策略显然不想放弃执行任务。但是由于池中已经没有任何资源了,那么就直接使用调用该execute的线程本身来执行。
-
ThreadPoolExecutor.DiscardOldestPolicy,如果执行程序尚未关闭,则位于工作队列头部的任务将被删除,然后重试执行程序(如果再次失败,则重复此过程).在pool没有关闭的前提下首先丢掉缓存在队列中的最早的任务,然后重新尝试运行该任务。这个策略需要适当小心。
-
ThreadPoolExecutor.DiscardPolicy .不能执行的任务将被删除,这种策略和AbortPolicy几乎一样,也是丢弃任务,只不过他不抛出异常。
工厂方法
在调用构造函数后再定制ThreadPoolExecutor
在创建线程池后,依然可以通过ThreadPoolExecutor提供的方法修改构造时传入的参数。
8. 活跃性危险
死锁
所谓死锁: 是指两个或两个以上的进程在执行过程中,因争夺资源而造成的一种互相等待的现象,若无外力作用,它们都将无法推进下去。
当两个以上的运算单元,双方都在等待对方停止运行,以取得系统资源,但是没有一方提前退出时,这种状况,就称为死锁。
-
顺序死锁
最少有两个锁,一个线程获取到A锁需要获取B锁才能进行操作,而另外一个线程获取到了B锁,需要获取A锁才能执行操作,这种情况下容易出现顺序死锁。
-
动态的锁顺序死锁
由外部传入的变量所有锁的条件,但是由以上传入的变量可以看到,这种情况下会出现一个线程先获取myAccount锁在申请yourAccount锁,而另外一个线程相反先获取yourAccount锁在申请myAccount锁。
-
在协作对象之间发生的死锁
如果在持有锁的情况下调用外部方法,需要格外小心
-
开放调用
如果在调用某个方法时不需要持有锁,那这种调用称为开放调用. 通过公开调用来避免在相互协作的对象之间产生死锁.
-
资源死锁
外部锁常被忽视而导致死锁,例如数据库的锁
二、死锁的避免与诊断
-
支持定时的死锁
存在一些预防死锁的手段,比如Lock的tryLock,JDK 7中引入的Phaser等。
-
通过线程转储信息来分析死锁
通过Dump线程的StackTrace,例如linux下执行命令 kill -3,或者jstack –l,或者使用Jconsole连接上去查看线程的StackTrace,由此来诊断死锁问题。
其他活跃性危险
-
饥饿:是指如果线程T1占用了资源R,线程T2又请求封锁R,于是T2等待。T3也请求资源R,当T1释放了R上的封锁后,系统首先批准了T3的请求,T2仍然等待。然后T4又请求封锁R,当T3释放了R上的封锁之后,系统又批准了T4的请求……,T2可能永远等待。
-
糟糕的响应性:某个线程长时间占有锁…
-
活锁:是指线程1可以使用资源,但它很礼貌,让其他线程先使用资源,线程2也可以使用资源,但它很绅士,也让其他线程先使用资源。这样你让我,我让你,最后两个线程都无法使用资源。
9.性能与可伸缩性
造成开销的操作包括:
-
线程之间的协调(例如:锁、触发信号以及内存同步等)
-
增加的上下文切换
-
线程的创建和销毁
-
线程的调度
对性能的思考
1 性能与可伸缩性
运行速度涉及以下两个指标:
性能:
某个指定的任务单元需要“多快”才能处理完成、计算资源一定的情况下,能完成“多少”工作。
可伸缩性:
当增加计算资源时(例如:CPU、内存、存储容器或I/O带宽),程序的吞吐量或者处理能力能相应地增加。
2 评估各种性能权衡因素
避免不成熟的优化。首先使程序正确,然后再提高运行速度—如果它还运行得不够快。
以测试为基准,不要猜测。
提出问题:例如“更快”的含义是什么?提升多少效率?
并发三大定律
Amdahl 定律
Gene Amdahl 发现在计算机体系架构设计过程中,某个部件的优化对整个架构的优化和改善是有上限的。这个发现后来成为知名的 Amdahl 定律。
(即使你有10个老婆,也不能一个月把孩子生下来。)
Gustafson 定律
Gustafson假设随着处理器个数的增加,并行与串行的计算总量也是可以增加的。Gustafson定律认为加速系数几乎跟处理器个数成正比,如果现实情况符合Gustafson定律的假设前提的话,那么软件的性能将可以随着处理个数的增加而增加。
(当你有10个老婆,就会要生更多的孩子。)
Sun-Ni 定律
充分利用存储空间等计算资源,尽量增大问题规模以产生更好/更精确的解。
(你要设法让每个老婆都在干活,别让她们闲着。 )
多线程中串行部分是性能提升的瓶颈 ,例如:
多线程在同一个队列中取出任务,因为需要保证线程安全肯定在队列上加锁,此时就是多线程中串行部分。
多线程通常是处理一些计算,而计算结果可能需要多个线程间进行共享,这也是多线程中串行部分。
线程引入的开销
1 上下文切换
大多数通用的处理器中,上下文切换的开销相当于5000~10000个时钟周期(几微妙)
UNIX系统的vmstat、mpstat命令和Windows系统的perfmon工具都能报告上下文切换次数以及和内核中执行时间所占比例等信息。
2 内存同步
在 synchronized 和 volatile 提供的可见性保证中可能会使用一些特殊指令,即内存栅栏(Memory Barrier)。
public String getStoogeNames(){
Vector
stooges.add( "Moe" );
stooges.add( "Larry" );
stooges.add( "Curly" );
return stooges.toString();
}
在执行getStoogeNames中,至少将Vector上的锁获取/释放4此,3次add操作与1次toString操作。
JVM会把在一起操作进行合并,可能仅需要获取1次add锁与1次toString锁。
同步会增加共享内存总线上的通信量,总线的带宽是有限的,会影响其他线程的性能.
3 阻塞
当线程无法获取某个锁或者由于某个条件等待或阻塞时,需要被挂起,过程中包含两次额外的上下文切换,以及有必要的操作系统操作和缓存操作.
减少锁的竞争
在并发程序中,对可伸缩性的最主要威胁就是独占方式的资源锁。
有3中方式可以降低锁的竞争程度:
-
减少锁的持有时间。
-
降低锁的请求频率。
-
使用带有协调机制的独占锁,这些机制允许更高的并发性。
1 缩小锁的范围(“快进快出”)
如果一个方法中,仅有一个变量是需要多线程间共享的,不需要在方法上添加synchronized,因为这样会直接锁住整个方法导致其多线程间穿行执行,可以通过方法中仅锁住对共享变量操作的部分来缩小锁的范围提高性能。
2 减小锁的粒度(锁分解)
在一个分装中,如果分别提供的多个方法是分别对多个数据源操作,最严谨的方式是在所有方法上都把当前类作为锁定条件,但是可以通过在每个数据源上添加一个独立的锁。
3 锁分段
上一个中是一个类中涉及到多个数据源,如果仅有一个数据源(例如:Map)如何提高性能?
在数据源上添加分段锁,例如把map的个数除以4,4份中每一份是用一个单独的锁来锁定。
4 避免热点域
前面提到的通常是针对一个变量、一个数据集合、多个数据集合提高性能的办法,但是有些情况下一个方法内涉及到多个变量或者同一个变量的多个操作.
一些优化措施,将一些反复计算的结果缓存,都会引入热点域,可以通过减少这种情况出现的次数提升性能。
例如hashmap的size变量属于特点域,ConcurrentHashMap中的size,为每个分段都维护了独立的计数.
5 一些替代独占锁的方法
通过放弃独占锁来提升性能。如并发容器,ReadWriteLock,不可变对象以及原子变量。
6 监测CPU的利用率
CPU没有得到充分利用的原因:
. 负载不充足
. I/O密集
. 外部限制
. 锁竞争
7 向对象池说“不”
对象分配操作的开销比同步的开销更低。
10. 显式锁
Lock与 ReentrantLock
Lock 提供一种无条件的、可轮询的、定时的、可中断的锁获取操作,所有加锁和解锁的方法都是显式的。
public interface Lock {
void lock(); // 获取锁。
void lockInterruptibly() throws InterruptedException; // 如果当前线程未被中断,则获取锁。
boolean tryLock(); // 仅在调用时锁为空闲状态才获取该锁。
// 如果锁在给定的等待时间内空闲,并且当前线程未被中断,则获取锁。
boolean tryLock( long time, TimeUnit unit) throws InterruptedException;
void unlock(); // 释放锁。
Condition newCondition(); // 返回绑定到此 Lock 实例的新 Condition 实例。 }
Lock lock = new ReentrantLock();
lock.lock();
try {
// 更新对象状态
// 捕获异常,并在必要时恢复不变性条件 } finally{
lock.unlock();
}
1 轮询锁与定时锁
通过tryLock来避免锁顺序死锁
2 可中断的锁获取操作
public boolean sendOnSharedLine(String message) throws InterruptedException {
lock.lockInterruptibly();
try {
return cancellableSendOnSharedLine(message);
} finally {
lock.unlock();
}
}
private boolean cancellableSendOnSharedLine(String message) throws InterruptedException {
}
3 非块结构的加锁
性能考虑因素
ReentrantLock在Java 5.0比内置锁提供更好的竞争性能。
Java 6使用了改进后的算法来管理内置锁,与在ReentrantLock中使用的算法类似,该算法有效地提高了可伸缩性。
公平性
ReentrantLock的构造函数提供两种公平性选择:非公平锁、公平锁。
针对Map的性能测试,公平锁、非公平锁、ConcurrentHashMap。非公平锁性能更高.
在synchronized和ReentrantLock之间进行选择
在一些内置锁无法满足需求的情况下,ReentrantLock可以作为一种高级工具。当需要一些高级功能时才应该使用ReentrantLock,这些功能包括:可定时的,可轮询的与可中断的锁获取操作,公平队列,及非块结构的锁。否则,还是应该优先使用synchronized.
读-写锁
1.当读写锁是写加锁状态时,在这个锁被解锁之前,所有试图对这个锁加锁的线程都会被阻塞
2.当读写锁在读加锁状态时,所有试图以读模式对它进行加锁的线程都可以得到访问权,但是以写模式对它进行加锁的线程将会被阻塞
3.当读写锁在读模式的锁状态时,如果有另外的线程试图以写模式加锁,读写锁通常会阻塞随后的读模式锁的请求,这样可以避免读模式锁长期占用,而等待的写模式锁请求则长期阻塞。
11. 构建自定义的同步工具
有界缓存的实现
1.将前提条件的失败传递给调用者
2.通过轮询与休眠来实现简单的阻塞
3.条件队列,使用wait,notifyAll
条件队列
1 条件谓词
要想正确地使用条件队列,关键是找出对象在哪个条件谓词上等待。
2 过早唤醒
例如:内置条件队列中有多个条件谓语,此时如果调用notifyAll其含义是通知所有wait,但是并不一定所有条件谓语都满足执行条件。
当使用条件等待时(例如Object.wait或Condition.await):
. 通常都有一个条件谓词–包括一些对象状态的测试,线程在执行前必须首先通过这些测试。
. 在调用wait之前测试条件谓词,并且从wait中返回时再次进行测试。
. 在一个循环中调用wait。
. 确保使用与条件队列相关的锁来保护构成条件谓词的各个状态变量。
. 当调用wait、notify或notifyAll等方法时,一定要持有与条件队列相关的锁。
. 在检查条件谓词之后以及开始执行相应的操作之前,不要释放锁。
3 丢失的信号
已经满足通知的条件发出通知,但是之后才进入阻塞wait状态,所以wait永远等不到在其前面发出的notify。
condition
Lock中可以用condition来设置多个条件,在每个锁上可存在多个等待,条件等待可以说可中断的和不可中断的,基于时限的等待,以及公平的或非公平的队列操作.
在Condition中,用await()替换wait(),用signal()替换 notify(),用signalAll()替换notifyAll(),对于我们以前使用传统的Object方法,Condition都能够给予实现。
AQS
如果我们去读concurrent包的源码时,会发现其真正的核心是 AbstractQueuedSynchronizer , 简称 AQS 框架.
首先,AQS会对进行 acquire 而被阻塞的线程进行管理,说是管理,其实就是内部维护了一个FIFO队列,这个队列是一个双向链表。链头可以理解为是一个空的节点,除了链头以外,每个节点内部都持有着一个线程,同时,有着两个重要的字段:waitStatus 和 nextWaiter。
nextWaiter一般是作用与在使用Condition时的队列。而waitStatus则有以下几个字段:
-
SIGNAL 表示下一个节点应该被唤醒。为什么是下一个节点?因为刚刚说了,这个FIFO队列,链头都是一个空的节点,但此节点的 waitStatus 正好就表示了要对下一节点做的事情
-
CANCELLED 表示此节点持有的线程被中断,或者该线程为null了。节点只能是暂时停留在此状态,因为在线程进入AQS时,线程会找机会整理链表,包括删除CANCELLED状态的节点。
-
CONDITION 表示此节点是在另一个队列中 —— condition队列中。比如我们使用的ReentrantLock.newCondition()获得Condition对象进行await时,在AQS内部所产生的节点。
-
PROPAGATE 顾名思义,传播。这点比较难理解,需要仔细推敲。因为此状态是为共享同步器使用的。加入此状态,可以避免无谓的线程 park 和 unpark。按照我对代码的理解,这是对多个线程并发获取共享同步器(比如acquireShared)所进行的优化,至少有3个线程并发,但想要优化效果明显的话,可能需要几十个线程并发的获取共享同步器(比如acquireShared),如果在并发量非常大的时候,对系统的吞吐量的作用应该不少。
-
AbstractQueuedSynchronizer内置一个state字段,用来表示某种意义——当ReentrantLock使用AQS的时候,state被用来表示锁被重入的次数;当’Semaphore’使用AQS的时候,state则被用来表示当前还有多少信号量可被获取。
AbstractQueuedSynchronizer 支持两种模式,分别是独占式和共享式。两者进行获取和释放动作的思路都是差不多的。
获取同步器的流程如下:
if(尝试获取成功){ return;
}else{
加入等待队列;
park自己;
}
释放同步器的流程如下:
if(尝试释放成功){
unpark等待队列中第一个节点;
}else{ return false;
}
JUC中的AQS
1 ReentrantLock
2 Semaphore与CountDownLatch
3 FutureTask
4 ReentrantReadWriteLock
12. 原子变量与非阻塞同步
锁的劣势
锁定后如果未释放,再次请求锁时会造成阻塞,多线程调度通常遇到阻塞会进行上下文切换,造成更多的开销。
在挂起与恢复线程等过程中存在着很大的开销,并且通常存在着较长时间的中断。
锁可能导致优先级反转,即使较高优先级的线程可以抢先执行,但仍然需要等待锁被释放,从而导致它的优先级会降至低优先级线程的级别。
硬件对并发的支持
处理器填写了一些特殊指令,例如:比较并交换、关联加载/条件存储。
1 比较并交换
CAS的含义是:“我认为V的值应该为A,如果是,那么将V的值更新为B,否则不需要修改告诉V的值实际为多少”。CAS是一项乐观锁技术。
2 非阻塞的计数器
基于CAS实现的非阻塞计数器
@ ThreadSafe
public class CasCounter {
private SimulatedCAS value ;
public int getValue(){
return value .get();
}
public int increment(){
int v;
do {
v = value .get();
} while (v != value .compareAndSwap(v, v + 1));
return v + 1;
}
}
CAS的主要缺点是:它将使调度者处理竞争问题(通过重试、回退、放弃),而在使用锁中能自动处理竞争问题(线程在获得锁之前将一直阻塞)。
3 JVM对CAS的支持
java.util.concurrent.atomic 类的小工具包,支持在单个变量上解除锁的线程安全编程。
AtomicBoolean 可以用原子方式更新的 boolean 值。
AtomicInteger 可以用原子方式更新的 int 值。
AtomicIntegerArray 可以用原子方式更新其元素的 int 数组。
AtomicIntegerFieldUpdater
AtomicLong 可以用原子方式更新的 long 值。
AtomicLongArray 可以用原子方式更新其元素的 long 数组。
AtomicLongFieldUpdater
AtomicMarkableReference
AtomicReference
AtomicReferenceArray
AtomicReferenceFieldUpdater
AtomicStampedReference
原子变量类
1 原子变量是一种“更好的volatile”
通过CAS来维持包含多个变量的不变性
2 性能比较:锁与原子变量
使用ReentrantLock、AtomicInteger、ThreadLocal比较,通常情况下效率排序是ThreadLocal > AtomicInteger > ReentrantLock。
非阻塞算法
1 非阻塞的栈
import java.util.concurrent.atomic.AtomicReference;
public class ConcurrentStack
private AtomicReference
public void push(E item){
Node
Node
do {
oldHead = top .get();
newHead. next = oldHead;
} while (!top .compareAndSet(oldHead, newHead));
}
public E pop(){
Node
Node
do {
oldHead = top .get();
if (oldHead == null) {
return null ;
}
newHead = oldHead. next ;
} while (!top .compareAndSet(oldHead, newHead));
return oldHead.item ;
}
private static class Node
public final E item;
public Node
public Node(E item){
this .item = item;
}
}
}
2 非阻塞的链表
CAS基本使用模式:在更新某个值时存在不确定性,以及在更新失败时重新尝试。
import java.util.concurrent.atomic.AtomicReference;
@ ThreadSafe
public class LinkedQueue
private static class Node
final E item;
final AtomicReference
public Node(E item, Node
this .item = item;
this .next = new AtomicReference
}
}
private final Node
private final AtomicReference
new AtomicReference
private final AtomicReference
new AtomicReference
public boolean put(E item){
Node
while (true ){
Node
Node
if (curTail == tail.get()){
if (tailNext != null){
// 队列处于中间状态,推进尾节点
tail.compareAndSet(curTail, tailNext);
} else {
// 处于稳定状态, 尝试插入新节点
if (curTail.next.compareAndSet( null, newNode)){
// 插入操作成功,尝试推进尾节点
tail.compareAndSet(curTail, tailNext);
return true ;
}
}
}
}
}
}
3 原子的域更新器
原子的域更新器类表示有volatile域的一种基于反射的“视图”,从而能够在已有的volatile域上使用CAS
private static class Node
private final E item;
private volatile Node
public Node(E item){
this.item = item;
}
}
private static AtomicReferenceFieldUpdater
= AtomicReferenceFieldUpdater.newUpdater(Node.class , Node.class , "next" );
4 ABA问题
处理V的值首先由A变成B,再由B变成A的问题。
13. java内存模型
基本概念
-
并发
定义:即,并发(同时)发生。在操作系统中,是指一个时间段中有几个程序都处于已启动运行到运行完毕之间,且这几个程序都是在同一个处理机上运行,但任一个时刻点上只有一个程序在处理机上运行。
并发需要处理两个关键问题:线程之间如何通信及线程之间如何同步。
(01) 通信 —— 是指线程之间如何交换信息。在命令式编程中,线程之间的通信机制有两种:共享内存和消息传递。
(02) 同步—— 是指程序用于控制不同线程之间操作发生相对顺序的机制。在Java中,可以通过volatile,synchronized, 锁等方式实现同步。
2.主内存和本地内存
主内存 —— 即main memory。在java中,实例域、静态域和数组元素是线程之间共享的数据,它们存储在主内存中。
本地内存 —— 即local memory。 局部变量,方法定义参数 和 异常处理器参数是不会在线程之间共享的,它们存储在线程的本地内存中。
3.重排序
定义:重排序是指“编译器和处理器”为了提高性能,而在程序执行时会对程序进行的重排序。
说明:重排序分为——“编译器”和“处理器”两个方面,而“处理器”重排序又包括“指令级重排序”和“内存的重排序”。
关于重排序,我们需要理解它的思想:为了提高程序的并发度,从而提高性能!但是对于多线程程序,重排序可能会导致程序执行的结果不是我们需要的结果!因此,就需要我们通过“volatile,synchronize,锁等方式”作出正确的实现同步。
4.内存屏障
定义:包括LoadLoad, LoadStore, StoreLoad, StoreStore共4种内存屏障。内存屏障是与相应的内存重排序相对应的。
作用:通过内存屏障可以禁止特定类型处理器的重排序,从而让程序按我们预想的流程去执行。
5.happens-before
定义:JDK5(JSR-133)提供的概念,用于描述多线程操作之间的内存可见性。如果一个操作执行的结果需要对另一个操作可见,那么这两个操作之间必须存在happens-before关系。
作用:描述多线程操作之间的内存可见性。
[程序顺序规则]:一个线程中的每个操作,happens- before 于该线程中的任意后续操作。
[监视器锁规则]:对一个监视器锁的解锁,happens- before 于随后对这个监视器锁的加锁。
[volatile变量规则]:对一个volatile域的写,happens- before 于任意后续对这个volatile域的读。
[传递性]:如果A happens- before B,且B happens- before C,那么A happens- before C。
6.数据依赖性
定义:如果两个操作访问同一个变量,且这两个操作中有一个为写操作,此时这两个操作之间就存在数据依赖性。
作用:编译器和处理器不会对“存在数据依赖关系的两个操作”执行重排序。
7.as-if-serial
定义:不管怎么重排序,程序的执行结果不能被改变。
8.顺序一致性内存模型
定义:它是理想化的内存模型。有以下规则:
(01) 一个线程中的所有操作必须按照程序的顺序来执行。
(02) 所有线程都只能看到一个单一的操作执行顺序。在顺序一致性内存模型中,每个操作都必须原子执行且立刻对所有线程可见。
9.JMM
定义:Java Memory Mode,即Java内存模型。它是Java线程之间通信的控制机制。
说明:JMM对Java程序作出保证——如果程序是正确同步的,程序的执行将具有顺序一致性。即,程序的执行结果与该程序在顺序一致性内存模型中的执行结果相同。
10.可见性
可见性一般用于指不同线程之间的数据是否可见。
在java中, 实例域、静态域和数组元素这些数据是线程之间共享的数据,它们存储在主内存中;主内存中的所有数据对该内存中的线程都是可见的。而局部变量,方法定义参数 和 异常处理器参数这些数据是不会在线程之间共享的,它们存储在线程的本地内存中;它们对其它线程是不可见的。
此外,对于主内存中的数据,在本地内存中会对应的创建该数据的副本(相当于缓冲);这些副本对于其它线程也是不可见的。
11.原子性
是指一个操作是按原子的方式执行的。要么该操作不被执行;要么以原子方式执行,即执行过程中不会被其它线程中断。
同步机制
1.volatile
作用
如果一个变量是volatile类型,则对该变量的读写就将具有原子性。如果是多个volatile操作或类似于volatile++这种复合操作,这些操作整体上不具有原子性。volatile变量自身具有下列特性:
[可见性]:对一个volatile变量的读,总是能看到(任意线程)对这个volatile变量最后的写入。
[原子性]:对任意单个volatile变量的读/写具有原子性,但类似于volatile++这种复合操作不具有原子性。
2.volatile的内存语义
volatile写:当写一个volatile变量时,JMM会把该线程对应的本地内存中的共享变量刷新到主内存。
volatile读:当读一个volatile变量时,JMM会把该线程对应的本地内存置为无效。线程接下来将从主内存中读取共享变量。
3.JMM中的实现方式
下面是基于保守策略的JMM内存屏障插入策略:
在每个volatile写操作的前面插入一个StoreStore屏障。
在每个volatile写操作的后面插入一个StoreLoad屏障。
在每个volatile读操作的后面插入一个LoadLoad屏障。
在每个volatile读操作的后面插入一个LoadStore屏障。
4.volatile和 synchronize对比
在功能上,监视器锁比volatile更强大;在可伸缩性和执行性能上,volatile更有优势。
volatile仅仅保证对单个volatile变量的读/写具有原子性;而synchronize锁的互斥执行的特性可以确保对整个临界区代码的执行具有原子性。
5.锁的内存语义
(01) 线程A释放一个锁,实质上是线程A向接下来将要获取这个锁的某个线程发出了(线程A对共享变量所做修改的)消息。
(02) 线程B获取一个锁,实质上是线程B接收了之前某个线程发出的(在释放这个锁之前对共享变量所做修改的)消息。
(03) 线程A释放锁,随后线程B获取这个锁,这个过程实质上是线程A通过主内存向线程B发送消息。
6.MM如何实现锁
公平锁
公平锁是通过“volatile”实现同步的。公平锁在释放锁的最后写volatile变量state;在获取锁时首先读这个volatile变量。根据volatile的happens-before规则,释放锁的线程在写volatile变量之前可见的共享变量,在获取锁的线程读取同一个volatile变量后将立即变的对获取锁的线程可见。
非公平锁
通过CAS实现的,CAS就是compare and swap。CAS实际上调用的JNI函数,也就是CAS依赖于本地实现。以Intel来说,对于CAS的JNI实现函数,它保证:(01)禁止该CAS之前和之后的读和写指令重排序。(02)把写缓冲区中的所有数据刷新到内存中。
7.final 特性
对于基本类型的final域,编译器和处理器要遵守两个重排序规则:
(01) final写:“构造函数内对一个final域的写入”,与“随后把这个被构造对象的引用赋值给一个引用变量”,这两个操作之间不能重排序。
(02) final读:“初次读一个包含final域的对象的引用”,与“随后初次读对象的final域”,这两个操作之间不能重排序。
对于引用类型的final域,除上面两条之外,还有一条规则:
(03) final写:在“构造函数内对一个final引用的对象的成员域的写入”,与“随后在构造函数外把这个被构造对象的引用赋值给一个引用变量”,这两个操作之间不能重排序。
注意:
写final域的重排序规则可以确保:在引用变量为任意线程可见之前,该引用变量指向的对象的final域已经在构造函数中被正确初始化过了。其实要得到这个效果,还需要一个保证:在构造函数内部,不能让这个被构造对象的引用为其他线程可见,也就是对象引用不能在构造函数中“逸出”。
8.JMM如何实现final
通过“内存屏障”实现。
在final域的写之后,构造函数return之前,插入一个StoreStore障屏。在读final域的操作前面插入一个LoadLoad屏障。
JMM总结
JMM保证:如果程序是正确同步的,程序的执行将具有顺序一致性 。
JMM设计
从JMM设计者的角度来说,在设计JMM时,需要考虑两个关键因素:
(01) 程序员对内存模型的使用。程序员希望内存模型易于理解,易于编程。程序员希望基于一个强内存模型(程序尽可能的顺序执行)来编写代码。
(02) 编译器和处理器对内存模型的实现。编译器和处理器希望内存模型对它们的束缚越少越好,这样它们就可以做尽可能多的优化(对程序重排序,做尽可能多的并发)来提高性能。编译器和处理器希望实现一个弱内存模型。
JMM设计就需要在这两者之间作出协调。JMM对程序采取了不同的策略:
(01) 对于会改变程序执行结果的重排序,JMM要求编译器和处理器必须禁止这种重排序。
(02) 对于不会改变程序执行结果的重排序,JMM对编译器和处理器不作要求(JMM允许这种重排序)。
学习Java的同学注意了!!!
学习过程中遇到什么问题或者想获取学习资源的话,欢迎加入Java学习交流群,群号码:184625948【长按复制】 我们一起学Java!