JAVA学习笔记--4.多线程编程 part3.JAVA多线程的常见概念和基本类库
常见概念
多线程的代价
多线程程序会额外增加CPU和内存的消耗,以及导致实现复杂.
在上下文切换过程中,CPU会停止处理当前运行的程序,并保存当前程序运行的具体位置以便之后继续运行。
在三种情况下可能会发生上下文切换:中断处理,多任务处理,用户态切换.
线程还需要一些内存来维持它本地的堆栈,也需要占用操作系统中一些资源来管理线程.
上下文切换会带来直接和间接两种因素影响程序性能的消耗. 直接消耗包括: CPU寄存器需要保存和加载, 系统调度器的代码需要执行, TLB实例需要重新加载, CPU 的pipeline需要刷掉; 间接消耗指的是多核的cache之间得共享数据变得无效了。
奇怪的术语
- 不变式:并发对象需要一直保持的特性。不变式是并发对象的各个方法之间必须遵守的“契约”,每个方法在调用前和调用后都必须保持不变式。采用不变式,就可以隔离的分析每个方法,而不用考虑它们之间所有可能的交互。
- 线程安全性是指"永远不发生糟糕的事情",线程活跃性是指"某件正确的事情最终会发生".
- 竞态条件:当两个线程竞争同一资源时,如果对资源的访问顺序敏感,就称存在竞态条件。换句话说,当某个计算的正确性取决于多个线程的交替执行方式时,那么就会发生竞态条件.
- 竞态条件:导致竞态条件发生的代码区称作临界区.在临界区中使用适当的同步就可以避免竞态条件。
- 可重入的意思是线程可以重复获得它已经持有的锁。当线程获取请求一个由其他线程获取的锁时,该线程会阻塞;当该线程获取一个由自己持有的锁时,如果这个请求会成功,我们称这个所是可以重入的. 一般而言,重入锁的实现方法是:线程标识+锁的计数器。Java的synchronized块是可重入的。ReentrantLock,顾名思义,也是可重入的。
- “不变”(Immutable)和“只读”(Read Only)是不同的。当一个变量是“只读”时,变量的值不能直接改变,但是可以在其它变量发生改变的时候发生改变。比如,一个人的出生年月日是“不变”属性,而一个人的年龄便是“只读”属性,但是不是“不变”属性。随着时间的变化,一个人的年龄会随之发生变化,而一个人的出生年月日则不会变化。这就是“不变”和“只读”的区别。(摘自《Java与模式》第34章)
JAVA同步的关键印象词
- synchronized
- 互斥锁,线程进入同步代码块前会自动获取锁,退出(包含正常,异常)代码块后会自动释放锁
- volatile
- 显式锁Lock
- 原子变量
Mutex 和 SpinLock
Mutex
Mutex属于sleep-waiting类型的锁,相当于的Java中的synchronized关键字。
在一个双核的机器上有两个线程(线程A和线程B),它们分别运行在Core0和Core1上。假设线程A想要通过pthread_mutex_lock操作去得到一个临界区的锁,而此时这个锁正被线程B所持有,那么线程A就会被阻塞(出于blocking状态),Core0 会在此时进行上下文切换(Context Switch)将线程A置于等待队列中,此时Core0就可以运行其他的任务(例如另一个线程C)而不必进行忙等待。
SpinLock
SpinLock属于busy-waiting类型的锁,也称自旋锁,相当于Java里面的那一堆以Atomic开头的类库。如果线程A是使用pthread_spin_lock操作去请求锁,那么线程A就会一直在 Core0上进行忙等待并不停的进行锁请求,直到得到这个锁为止。
一般要求使用SpinLock的临界区尽量简短,这样获取的锁可以尽快释放,以满足其他忙等的线程。如果SpinLock使用不当(如临界区执行时间过长),则会导致cpu busy飙高。
两者对比
- Spinlock优点:没有昂贵的系统调用,不会导致线程的状态切换(用户态->内核态),执行速度快
- Spinlock缺点:一直占用cpu,而且在执行过程中还会锁bus总线,锁总线时其他处理器不能使用总线.
- Mutex优点:不会忙等,得不到锁会sleep.
- Mutex缺点:sleep时会陷入到内核态,需要昂贵的系统调用
Wait 和 Notify
Wait
- 调用wait方法会产生如下操作:如果当前线程已经终止,那么这个方法会立即退出并抛出一个InterruptedException异常。否则当前线程就进入阻塞状态。 Java虚拟机将该线程放置在目标对象的等待集合中。释放目标对象的同步锁,但是除此之外的其他锁依然由该线程持有。即使是在目标对象上多次嵌套的同步调用,所持有的可重入锁也会完整的释放。
- wait(long mesecs)和wait(long msecs,int nanosecs),参数指定了需要在等待集合中等待的最大时间值。如果在时间限制之内没有被唤醒,它将自动释放,除此之外,其他的操作都和无参数的wait方法一样。并没有状态能够表明线程正常唤醒与超时唤醒之间的不同。需要注意的是,wait(0)与wait(0,0)方法其实都具有特殊的意义,其相当于不限时的wait()方法,这可能与你的直觉相反。
Notify
- 调用Notify会产生如下操作:Java虚拟机从目标对象的等待集合中随意选择一个线程(称为T,前提是等待集合中还存在一个或多个线程)并从等待集合中移出T。当等待集合中存在多个线程时,并没有机制保证哪个线程会被选择到。线程T必须重新获得目标对象的锁,直到有线程调用notify释放该锁,否则线程会一直阻塞下去。如果其他线程先一步获得了该锁,那么线程T将继续进入阻塞状态。线程T从之前wait的点开始继续执行。
线程中断
JAVA中没有一种安全的抢占式方法来停止线程,只有一些协作时机制.有如下两种典型的协作策略:
设置”请求取消”标识
周期性的查看.如果设置了该标志,那么任务将提前结束;缺陷是,如果该任务阻塞在一个调用中(比如进行一个长时间的服务调用),那么该任务则不能及时响应任务取消
使用线程中断
- JVM不保证阻塞方法检测到中断的速度,但在实际应用响应速度还是很快的。
- 当线程处于下列方法的调用时,interrupt方法会清除中断状态,并抛出中断异常
- 也就方法声明直接抛出InterruptedException的:Object的wait(), wait(long), or wait(long, int) 方法, Thread的join(), join(long), join(long, int), sleep(long), 或者 sleep(long, int)
- 当线程下列情形时,interrupt方法会设置中断状态,但是并不抛出中断异常
- 在 interruptible channel进行IO操作时, 该channel会被关闭,然后抛出java.nio.channels.ClosedByInterruptException.
- 线程阻塞在java.nio.channels.Selector 时,则线程会立即返回一个非0的值.就好像调用了该Selector的wakeup方法.
- 其他未说明的情况(比如在catch IE后,需要传递中断状态,则调用Thread.currentThread.interrupt(),但是此时不满足上述几种情况,则仅仅是设置线程中断状态,不会抛出IE异常)
- 传递InterruptedException
- 根本不捕获异常
- 捕获该异常,简单处理后,再次抛出该异常
- 恢复中断
- 由于jvm在抛出IE异常后,会自动将中断状态置为false.所以为了调用栈中更高层的看到这个中断,则需要执行
Thread.currentThread.interrupt()
,这样做仅仅是为了设置线程中断状态为true.
- 由于jvm在抛出IE异常后,会自动将中断状态置为false.所以为了调用栈中更高层的看到这个中断,则需要执行
修复多线程同步不当的问题
- 不在线程之间共享状态变量值
- 将状态变量修改为不可变的变量
- 在访问状态变量时使用同步
设计线程安全类的三要素
- 找出构成对象状态的所有要素(看类的属性)
- 找出约束状态变量的不变性条件(也称不变式,比如状态之间的关联关系,具体例如Produce/Consume,队列满时不能放,队列空时不能取等)
- 建立对象状态的并发访问管理策略
阻塞
- 阻塞原因
- 等待I/O操作结束
- 等待获得一个锁
- 等待从Thread.sleep方法中醒来
- 等待另外线程的计算结果
- 阻塞状态
- BLOCKED
- WAITING
- TIMED_WAITING
- 阻塞和执行很长时间操作的区别
- 被阻塞的线程必须等待某个不受它控制的事件结束后才能继续执行.线程被置回RUNNABLE状态,并可以被继续调度执行
其他杂项
- 实现Runnable接口优于继承Thread覆写run方法。原因如下:
- 避免Thread方法的干扰
- 有着清晰的上下文
- 可以继承其他类
- 当一个线程正常地运行结束或者抛出某种未检测的异常(比如,运行时异常(RuntimeException),错误(ERROR) 或者其子类)线程就会终止。当线程终止之后,是不能被重新启动的。在同一个Thread上调用多次start方法会抛出InvalidThreadStateException异常。
- synchronized关键字并不是方法签名的一部分。所以当子类覆写父类中的同步方法或是接口中声明的同步方法的时候,synchronized修饰符是不会被自动继承的。
- 用户线程和守护线程两者几乎没有区别,唯一的不同之处就在于虚拟机的离开:如果用户线程已经全部退出运行了,只剩下守护线程存在了,虚拟机也就退出了。 因为没有了被守护者,守护线程也就没有工作可做了,也就没有继续运行程序的必要了。
- 生产者/消费者:类似于洗盘子。生产者把洗好的盘子放在盘架上,消费者把盘架上的盘子烘干。
基本类库概要说明
- Iterable
- Collection
- List
- Set
- Queue
- BlockingQueue
- LinkedBlockingQueue
- ArrayBlockingQueue
- PriorityBlockingQueue
- BlockingQueue
- Deque
Map
- SortedMap
ConcurrentHashMap
- 内部使用了分段锁机制(Lock Striping),这种机制允许任意数量的读取线程可以并发地访问Map,并且允许一定数量的线程可以并发地修改Map
- CHM返回的迭代器具有弱一致性(Weakly Consistent),而并非"及时失败".弱一致性的迭代器可以容忍并发修改,并可以(但并不保证)在迭代器被构造后将修改操作反映给容器
CopyOnWriteArrayList
- 在每次修改时,都会创建并重新发布一个新的容器副本.
- 适用于读多写少的场景,常用于监听器模式.
Deque
- 双端队列,支持在队列头和队列尾实现高速插入和移除
同步工具类
CountDownLatch(闭锁)
- Latch 译为 门闩
- 简单理解:一开始,该Latch时关闭的.内部有个计数器,每调用一次countDown方法,计数器减一.当计数器为0时,该Latch打开,在latch上await的线程可以继续执行.当Latch打开后,其状态不会再改变.(也就是说,要新建一个CountDownLatch实例才能完成相同功能)
- 通常用于确保某些活动在其他活动都完成后才继续执行.
- 实例伪码
CountDownLatch latch = new CountDownLatch(10); Thread t = new Runnable({ public void run(){ latch.await; task.run(); } }) t.start; latch.countDown();
CyclicBarrier
- 所有线程必须都已经到达栅栏(或者await调用超时或者被中断),才能开始继续进行.
- vs CountDownLatch
- Barrier 等待所有线程到达栅栏处,比如统计运动员的比赛成绩
- Latch 主要取决于刚开始计数器的大小以及countDown方法的调用次数
- Barrier计数器可以重置,Latch内置计数器不可以
- FutureTask
- 常用于并行计算
- 任务状态
- 等待运行
- 正在运行
- 完成运行
- 任务正常完成结束
- 任务异常结束
- 取消任务执行
- 任务执行超时
- 使用FutureTask.get
- 处理异常
- Semaphore
- 常用于控制同时访问某个特定资源/执行特定操作的数量(比如连接池)
- 使用acquire来获得(占用)许可(如果没有许可将阻塞);使用release来释放许可
- 不可重入
参考
- 《Java并发编程实战》
- 并发编程网
- spinlock剖析与改进
- 非阻塞算法在并发容器中的实现
blog comments powered by Disqus