并发编程基础
线程基本概念
多线程与多进程的区别: 每个进程拥有自己的一整套变量,而线程则共享数据。
并行运行多个任务
创建
- new Thread(Runnable);
- 继承Thread的创建方式不再推荐,应该将并行运行的任务与运行机制解耦合;
- 线程池:当任务很多时,为每个任务创建一个独立的线程付出代价太大。(14.9)
注:Callabe类似于Runnable接口,表示具有返回值的异步任务。FutureTask类实现了Runnable、Funture接口,可用于Callabe与Runnable、Futrue之间的转换。
启动
thread.start()
中断
没有强制线程终止的方法,stop与suspend方法已经不推荐使用,interrupt方法可用来请求终止线程。
一般情况,线程要时不时的检测interrupt状态。
while (!Thread.currentThread().islnterrupted() && more work to do) {
do more work
}
当在一个被阻塞的线程(调用 sleep 或 wait) 上调用 interrupt方法时,阻塞调用将会被Interrupted Exception异常中断。(也存在不会被中断的阻塞I/O调用,应该考虑选择可中断的调用)。
被中断的线程可自行决定如何响应中断,普遍情况下是终止线程:
Runnable r = () -> {
try
{
. . .
while (!Thread.currentThread().isInterrupted() && more work to do) {
do more work
}
}
catch(InterruptedException e) {
// thread was interrupted during sleep or wait
}
finally
{
cleanup,if required
}
// exiting the run method terminates the thread
};
注:如果在循环体内调用sleep方法,当中断状态被置位时,它不会休眠,相反,会清除这一状态,并抛出异常。因此,此时在while循环处没有必要进行isInterrupted的检测,删除检测状态的代码即可。
Thread.interrupted() 和 thread.isInterrupted 比较:静态方法会清除中断状态。
不要忽略中断异常,更好的处理方式:
-
sleep方法被中断时会清除状态,因此可在异常处理中重新设置状态,以便调用方进行检测
void mySubTask() { …… try { sleep(delay); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } …… }
-
throws InterruptedException标记方法
void mySubTask() throws InterruptedException
状态getState()
- New
还没有开始运行 - Runnable
可能没有在运行,取决于操作系统调度机制(抢占式-时间片、协作式) - Blocked、Waiting、Timed waiting
- 试图获取一个内部对象锁,而该锁被其他线程持有,阻塞;
- 当线程等待另一个线程通知调度器一个条件时(wait、join或是等待Lock或Condition时),等待;
- wait、join、tryLock、await、sleep 带有超时参数的方法被调用时,进入计时等待。
- Terminated
- run方法正常退出
- 没有捕获的异常意外死亡
- stop方法(过时)
被阻塞状态与等待状态有哪些不同?
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-gt0NmIDZ-1582987902984)(evernotecid://1520493E-927F-420A-8EE1-BA6F74088A9D/appyinxiangcom/11767354/ENResource/p3036)]
属性
- 优先级
高度依赖于系统:1 ~ 10 映射到OS时有可能更多,也有可能更少。Oracle为Linux提供的Java虚拟机中,线程的优先级被忽略。
不要将程序构建为功能的正确性依赖于优先级。
调度器优先选择高优先级线程:若高优先级线程没有进入非活跃状态,则低级别线程永远不能执行
Thread.yield() 让出CPU
-
守护线程setDaemon
–为其他线程提供服务(示例:计时线程),只剩下守护线程时,虚拟机退出。
–使用:不要访问固有资源,如文件、数据库,它会在任何时候发生中断。
在线程启动之前调用setDaemon(true) -
Thread.UncaughtExceptionHandler
setUncaughtExceptionHandler
如果有父线程组(ThreadGroup),调用父线程组这一方法;否则,若Thread对象有默认处理器则调用;否则,如果Throwable是ThreadDeath的一个实例(stop方法产生),什么都不做;否则,输出栈轨迹到标准错误流。
线程组是一个可以统一管理的线程集合。默认情况下,创建的所有线程属于相同的线程组, 但是, 也可能会建立其他的组。现在引入了更好的特性用于线程集合的操作,所以建议不要在自己的程序中使用线程组。
同步
如何控制线程之间的交互
锁和条件
竞争条件race condition(多个线程修改相同对象产生讹误的对象)
出现原因:方法的执行过程可能被中断(非原子操作)(javap -c -v classname可查看类的字节码,其中可能一条java语句生成多条虚拟机指令,运行可能被中断,而造成讹误的对象出现)
解决:锁和条件(Lock/Condition 或 synchronized)
-
Lock / ReentrantLock
基本使用结构:private Lock bankLock = new ReentrantLock(); //object field myLock.lock(); // a ReentrantLock object, a share object,second thread whill bolcked try { critical section } finally { myLock.unlock();// make sure the lock is unlocked even if an exception is thrown }
注:如果使用锁,就不能使用带资源的try语句,带资源的try语句希望首部声明新变量,而lock,我们要使用多线程共享的那个变量;
注:注意编写临界区内的代码,避免因为抛出异常跳出临界区,造成讹误对象的出现。可重入的锁保持一个持有计数hold count,记录调用lcok方法的嵌套调用次数。被一个锁保护的代码,可以调用另一个使用相同锁保护的方法。
公平锁(带参数fair的构造方法)
公平锁认为等待时间越长的线程越应该得到执行,但这会影响性能,公平锁默认是不公平的。只有确定自己需要使用公平锁的需求时,才可以考虑公平锁,即使使用了,线程调度器也有可能选择忽略一个线程,而这个线程已经等待了锁很长时间的情况。 -
条件对象 / 条件变量
为什么需要条件对象?
获得锁并进入临界区的线程,发现某个条件满足后才能继续执行,需要等待另一个线程修改了共享对象状态后,再进行检测条件,满足了再执行。
举例:如果一个转账操作获得了锁,临界区内,在转账之前检测到余额不足,这时需要等待其他线程先进行转账,待余额充足后再继续执行。一个锁对象,可以有多个相关的条件对象。使用:
① sufficientFunds = lock.newCondition(); // 返回一个与锁相关的条件对象
② 不满足条件,需要阻塞时调用sufficientFunds.await(),执行线程阻塞,释放锁,寄希望于其他线程;
③ 等待其他线程调用同一条件的signalAll方法。
signalAll() – 解除该条件等待集中所有线程的阻塞状态,不会立即激活一个等待线程,以便这些线程可以在当前线程退出同步方法之后,通过竞争实现对对象的访问。
调用时机:在对象的状态有利于等待线程的方向改变时调用。例如转账完成,账户余额发生变化时。
等待获得锁的线程和调用await方法的线程存在本质上的不同。一旦一个线程调用await方法, 它进人该条件的等待集。当锁可用时,该线程不能马上解除阻塞。相反,它处于阻塞状态,直到另一个线程调用同一条件上的signalAll方法时为止。
重新获得锁的线程需要重新检测是否满足条件。一般调用await的方式:
while(!(ok to proceed))
condition.await(); // 将线程放到条件的等待集中
另一个signal方法,随机解除等待集中某个线程的状态,更加有效,但存在危险:有可能基础阻塞的线程仍然不能运行
同步机制中的簿记操作付出的代价,程序运行可能会慢。正确使用条件对象富有挑战性,在实现自己的条件对象之前优先考虑使用同步器相关结构。
小结:Lock与Condition对象
• 锁用来保护代码片段, 任何时刻只能有一个线程执行被保护的代码。
• 锁可以管理试图进入被保护代码段的线程。
• 锁可以拥有一个或多个相关的条件对象。
• 每个条件对象管理那些已经进入被保护的代码段但还不能运行的线程。
-
synchronized
每一个对象都有一个内部锁,并且该锁有一个内部条件。
wait、notifyAll、notify 等同于 await、signalAll、signal也可声明静态方法,调用方法获得 类对象 的内部锁
优势:代码简洁,不易出错
局限:
• 不能中断一个正在试图获得锁的线程。
• 试图获得锁时不能设定超时。
• 每个锁仅有单一的条件,可能是不够的。应该使用 Lock/Condition 还是 synchronized ?
• 最好既不使用Lock/Condition也不使用 synchronized 关键字。在许多情况下可以使用 java.util.concurrent 包中的一种机制,它会为你处理所有的加锁。
• 如果 synchronized 关键字适合你的程序, 那么请尽量使用它,这样可以减少编写的代码数量,减少出错的几率。
• 如果特别需要 Lock/Condition 结构提供的独有特性时,才使用 Lock/Condition。同步阻塞:synchronized(obj){}
使用一个对象的锁实现额外的原子性操作,称为客户端锁定(client side locking),依赖于内部实现阻塞的事实,因此这个机制是脆弱的。
安全访问共享域
- volatile
问题:多线程读写同一个实例域出现不一致的问题
- 寄存器或本地内存缓冲区中保存内存中的值,不同处理器线程在同一内存位置取到不同的值。
- 改变指令执行顺序使吞吐量最大化
volatile为实例域的同步访问提供了免锁机制。如果声明一个域为 volatile ,那么编译器和虚拟机就知道该域是可能被另一个线程并发更新的。
volatile变量不能保证原子性,不能保证读取、翻转和写入不被中断。
“ 如果向一个变量写入值, 而这个变量接下来可能会被另一个线程读取, 或者,从一个变量读值, 而这个变量可能是之前被另一个线程写入的, 此时必须使用同步 “–同步格言。
-
final变量
final Map<String, Double> accounts = new HashKap<>(); -
原子性
- 只是赋值操作,可使用volatile
- 原子方式设置或增减值操作,可使用automic包下的一些类(提供了机器级指令)
- 要完成更复杂的更新,可使用compareAndSet(例如:跟踪不同线程观察的最大值)
public static AtonicLong largest = new AtomicLong();
do {
oldValue = largest.get();
newValue = Math.max(oldValue , observed);
} while (largest.compareAndSet(oldValue, newValue));
注:compareAndSet方法会映射到一个处理器操作,比使用锁速度更快。
Java8以后的简化写法:
largest. updateAndGet(x -> Math .max(x, observed));
或
1argest.accumulateAndCet(observed , Math::max);
(getAndUpdate 和 getAndAccumulate可以返回原值)
如果有大量线程要访问相同的原子值,性能会大幅下降,因为乐观更新需要太多次重试(乐观锁的机制)。Java SE 8 提供了 LongAdder 和 LongAccumulator 类。
LongAdder提供多个加数(变量)对应不同线程,方法:increment、sum
LongAccumulator 将这种思想推广到任意的累加操作。accumulate、get(需要满足交换律与结合律)
(类似的类:DoubleAdder、DoubleAccumulator)
死锁
有可能出现每一个线程要等待条件对象被激活的情况,导致了所有线程都被阻塞,这样的状态被称为死锁(dead lock)。
通过jconsole观察死锁线程的调用栈,必须仔细设计线程,确保不会出现死锁。(signal可能导致死锁)
线程局部变量ThreadLocal
public static final ThreadLocal<SimpleDateFormat> dateFormat = ThreadLocal.withInitial(() -> new SimpleDateFormat("yyyy-MM-dd"));
dateFormat.get().format(new Date());
int random = ThreadLocalRandom.curren().nextlnt(upperBound):
锁测试与超时
lock方法不能被中断。如果一个线程在等待获得一个锁时被中断,中断线程在获得锁之前一直处于阻塞状态。如果出现死锁,那么,lock方法就无法终止。
然而,如果调用带有用超时参数的tryLock,那么如果线程在等待期间被中断,将抛出
InterruptedException 异常。这是一个非常有用的特性,因为允许程序打破死锁。
也可以调用locklnterruptibly方法。它就相当于一个超时设为无限的tryLock方法。
tryLock(time)、await(time) 时间到了返回false、awaitUninterruptibly()
ReentrantReadWriteLock
.readLock() //得到一个可以被多个读操作共用的读锁,但会排斥所有写操作。
.writeLock() //得到一个写锁,排斥所有其他的读操作和写操作。
stop、suspend、resume弃用原因
- stop弃用原因:停止线程可能导致对象处于损坏的状态,无法知道何时调用stop方法是安全的,什么时候会导致对象被破坏。ThreadDeath异常将释放所有锁对象
- suspend弃用原因:容易造成死锁。如果suspend挂起一个持有锁的线程,而调用suspend方法的线程也要获得同一个锁,则程序死锁。
线程安全的集合
阻塞队列BlockingQueue
方法(按照队列满或者空时的响应方式)
① 操作阻塞:put、take方法(作线程管理工具)
② 抛异常:add、remove、element
③ 错误提示:offer、poll、peek(多线程操作)
实现类:
ArrayBlockingQueue(int capacity)
LinkedBlockingQueue() // 默认无限,容量可选
DelayQueue() // 延迟已经超过时间的元素可以从队列中移出
PriorityBlockingQueue() // 优先级队列
接口:
BlockingQueue
BiockingDeque
TransferQueue
高效的映射、集合队列
-
ConcurrentHashMap
-
原子更新:
循环执行replace方法 或
map.putlfAbsent(word, new LongAdder()).increment();
或
map.compute(word , (k, v) -> v = null ? 1: v + 1);
或
map.computelfAbsent(word , k -> new LongAdder()).increment();
或
map.merge(word, 1L, (existingVal, newVal) -> existingVal + newVal);
注:compute、merge参数中的函数返回null,则会从map中删除现有条目。且函数不要做太多工作,可能会阻塞对映射的其他更新,且不能更新映射的其他部分。 -
批操作:映射状态的一个近似
search、forEach、reduce(Keys、Values、KV、Entries)- 可指定参数化阈值,映射包含元素多于阈值,会并行完成批操作(1 ~ Long.MAX_VALUE)
- forEach、reduce可指定一个转换函数,结果为null时可实现过滤效果
- 原始类型特化
-
并发Set视图
Set words = ConcurrentHashMap.newKeySet();
keySet方法可生成现有映射的键集,能够删除,不能增加元素;重载keySet(defaultVal)可增加元素。
-
-
写数组拷贝
CopyOnWriteArrayList 和 CopyOnWriteArraySet- 使用场景:迭代线程数超过修改线程数
- 一致性:可能过时的一致性
-
同步包装器
- 在另一个线程可能进行修改时要对集合进行迭代时,仍然需要使用“客户端”锁定,若在迭代过程中,别的线程修改集合,迭代器会失效,抛出ConcurrentModificationException异常,同步仍然是需要的,并发的修改可以被可靠地检测出来;
- 最好使用java.util.concurrent包中定义的集合,不使用同步包装器中的。特别是,假如它们访问的是不同的桶,由于ConcurrentHashMap已经精心地实现了,多线程可以访问它而且不会彼此阻塞。有一个例外是经常被修改的数组列表。在那种情况下,同步的ArrayList可以胜过CopyOnWriteArrayList
ConcurrentSkipListMap
ConcurrentSkipListSet
ConcurrentSkipListQueue
执行器(Executor)
线程池
为什么使用线程池?
- 构建新线程,涉及与操作系统交互,用于创建创建大量生命周期很短的线程;
(如果有很多任务, 要为每个任务创建一个独立的线程所付出的代价太大了) - 减少并发线程的数目
Executors:
创建线程池,返回ExecutorService接口的实现类ThreadPoolExecutor的对象
使用流程:
1. 调用 Executors 类中静态的方法 newCachedThreadPool 或 newFixedThreadPool。
2. 调用 submit 提交 Runnable 或 Callable 对象。
3. 如果想要取消一个任务, 或如果提交 Callable 对象, 那就要保存好返回的 Future 对象。
4. 当不再提交任何任务时,调用 shutdown。
控制任务组
- shutdownNow
- invokeAny
- invokeAll
- ExecutorCompletionService将结果按可获得的顺序保存起来
Fork-Join框架
- 针对每个处理内核使用一个线程完成计算密集型任务的应用使用
- RecursiveTask
- RecursiveAction
compute、invokeAll、join、get
注:框架使用了”工作密取(work stealing)“的方法来平衡可用线程的工作负载
可完成Future
可以组合(composed),指定执行顺序
- 单个future的方法:
- thenApply
- thenCompose
- handle
- 组合多个future的方法:
- thenCombine
- runAfterBoth
- applyToEither
- allOf
- anyOf
同步器
-
信号量
类:Semaphore(acquire、release)
作用:允许线程集等待,直到被允许继续运行为止
场景:限制访问资源的线程总数 -
倒计时门栓
类:CountDownLatch
作用:允许线程集等待,直到计数器减为0
场景:当一个或多个线程等待,直到指定数量的事件发生
一次性,不能重复使用,例如初识数据准备 -
障栅
类:CyclicBarrier barrier = new CydicBarrier(nthreads); 每个线程,执行到障栅时调用await,障栅动作可选。(Phaser类更灵活)
作用:允许线程集等待直至其中预定数目的线程到达一个公共障栅( barrier,) 然后可以选择执行一个处理障栅的动作
场景:当大量的线程需要在它们的结果可用之前完成时 -
交换器
类:Exchanger
作用:允许两个线程在要交换对象准备好时交换对象。
场景:当两个线程工作在同一数据结构的两个实例上的时候, 一个向实例添加数据而另一个从实例清除数据。 -
同步队列
类:synchronousQueue(put方法将阻塞,直到另一个线程take为止,反之亦然,size永为0)
作用:允许一个线程把对象交给另一个线程
场景:在没有显式同步的情况下, 当两个线程准备好将一个对象从一个线程传递到另一个时
小结
本文整理了Java并发基础相关知识,从线程的创建、启动、中断开始,到线程间的交互和协调,以及Java类库提供的同步工具、线程安全集合、线程池等内容,为并发程序的开发提供基础储备。
欢迎关注我的公众号,了解更多内容: