标题:之前看过的并发编程艺术这个pdf的知识点整理,可能有点乱
我们在乎的知识不是格式哈哈
内容:
一:
1. volatile的使用优化(解决共享变量伪共享):Java 7及以下使用多余字节追加到64字节 , 使用追加到64字节的方式来填满高速缓冲区的缓存行,避免头节点和尾节点加载到同一个缓存 行,使头、尾节点在修改时不会互相锁定
Java 8中,可以采用@Contended在类级别上的注释,来进行缓存行填充;
不适应场景:1、并非64位字节宽的处理器如奔腾处理器 2.并非经常被写的共享变量;
2.
Striped64是在java8中添加用来支持累加器的并发组件,它可以在并发环境下使用来做某种计数,Striped64的设计思路是在竞争激烈的时候尽量分散竞争,在实现上,Striped64维护了一个base Count和一个Cell数组,计数线程会首先试图更新base变量,如果成功则退出计数,否则会认为当前竞争是很激烈的,那么就会通过Cell数组来分散计数,
Striped64根据线程来计算哈希,然后将
不同的线程分散到不同的Cell数组的index上,
然后这个线程的计数内容就会保存在该Cell的位置上面,基于这种设计,最后的总计数需要结合base以及散落在Cell数组中的计数内容
比如两个线程都执行一个 i++;之前需要加锁,现在只要 i+两个线程的cell[]数组中的值就是总的值
简单的流程:
-
longAccumulate会根据当前线程来计算一个哈希值,然后根据算法(hashCode & (length - 1))来达到取模的效果以定位到该线程被分散到的Cell数组中的位置
-
如果Cell数组还没有被创建,那么就去获取 cellBusy这个共享变量(相当于锁,但是更为轻量级),如果获取成功,则初始化Cell数组,初始容量为2,初始化完成之后将x保证成一个Cell,哈希计算之后分散到相应的index上。如果获取cellBusy失败,那么会试图将x累计到base上,更新失败会重新尝试直到成功。
-
如果Cell数组以及被初始化过了,竞争cellBusy变量 那么就根据线程的哈希值分散到一个Cell数组元素上,获取这个位置上的Cell并且赋值给变量a,这个a很重要,如果a为null,说明该位置还没有被初始化,那么就初始化;
-
如果Cell数组的大小已经最大了(CPU的数量),那么就需要重新计算哈希,来重新分散当前线程到另外一个Cell位置上再走一遍该方法的逻辑,否则就需要对Cell数组进行扩容,然后将原来的计数内容迁移过去。这里面需要注意的是,因为Cell里面保存的是计数值,所以在扩容之后没有必要做其他的处理,直接根据index将旧的Cell数组内容直接复制到新的Cell数组中就可以了
LongAdder extends Striped64 提供add方法:对没有更新操作的加运算效率高,更新操作会有数据丢失问题 LongAdder 继承自 Striped64,它的方法只针对简单的情况:cell存在且更新无竞争,其余情况都通过 Striped64 的longAccumulate方法来完成。
1. 单线程下synchronized效率最高(当时感觉它的效率应该是最差才对);
2. AtomicInteger效率最不稳定,不同并发情况下表现不一样:短时间低并发下,效率比synchronized高,有时甚至比LongAdder还高出一点,但是高并发下,性能还不如synchronized,不同情况下性能表现很不稳定;
3. LongAdder性能稳定,在各种并发情况下表现都不错,整体表现最好,短时间的低并发下比AtomicInteger性能差一点,长时间高并发下性能最高(可以让AtomicInteger下台了);
二:
synchronized锁
(可重入锁
)的优化(
偏向锁->轻量级锁->自旋锁->重量级锁
)
偏向锁
Java偏向锁(Biased Locking)是Java6引入的一项多线程优化。
偏向锁,顾名思义,它会偏向于第一个访问锁的线程,如果在运行过程中,同步锁只有一个线程访问,不存在多线程争用的情况,则线程是不需要触发同步的,这种情况下,就会给线程加一个偏向锁。
如果在运行过程中,
遇到了其他线程抢占锁,则持有偏向锁的线程会被挂起,JVM会消除它身上的偏向锁,将锁恢复到标准的轻量级锁。
偏向锁的实现
偏向锁获取过程:
1.访问Mark Word中偏向锁的标识是否设置成1,锁标志位是否为01,确认为可偏向状态。
2.如果为可偏向状态,则测试线程ID是否指向当前线程,如果是,进入步骤5,否则进入步骤3。
3.如果线程ID并未指向当前线程,则通过CAS操作竞争锁。如果竞争成功,则将Mark Word中线程ID设置为当前线程ID,然后执行5;如果竞争失败,执行4。
4.如果CAS获取偏向锁失败,则表示有竞争。当到达全局安全点(safepoint)时获得偏向锁的线程被挂起,偏向锁升级为轻量级锁,然后被阻塞在安全点的线程继续往下执行同步代码。(撤销偏向锁的时候会导致stop the word)
5.执行同步代码。
注意:第四步中到达安全点safepoint会导致stop the word,时间很短。
偏向锁的释放:
偏向锁的撤销在上述第四步骤中有提到。偏向锁只有遇到其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁,线程不会主动去释放偏向锁。偏向锁的撤销,需要等待全局安全点(在这个时间点上没有字节码正在执行),它会首先暂停拥有偏向锁的线程,判断锁对象是否处于被锁定状态,撤销偏向锁后恢复到未锁定(标志位为“01”)或轻量级锁(标志位为“00”)的状态。
偏向锁的适用场景
始终只有一个线程在执行同步块,在它没有执行完释放锁之前,没有其它线程去执行同步块,在锁无竞争的情况下使用,一旦有了竞争就升级为轻量级锁,升级为轻量级锁的时候需要撤销偏向锁,撤销偏向锁的时候会导致stop the word操作;
在有锁的竞争时,偏向锁会多做很多额外操作,尤其是撤销偏向所的时候会导致进入安全点,安全点会导致stw,导致性能下降,
这种情况下应当禁用;
查看停顿–安全点停顿日志
要查看安全点停顿,可以打开安全点日志,通过设置JVM参数 -XX:+PrintGCApplicationStoppedTime 会打出系统停止的时间,添加-XX:+PrintSafepointStatistics -XX:PrintSafepointStatisticsCount=1 这两个参数会打印出详细信息,可以查看到使用偏向锁导致的停顿,时间非常短暂,但是争用严重的情况下,停顿次数也会非常多;
注意:安全点日志不能一直打开:
1. 安全点日志默认输出到stdout,一是stdout日志的整洁性,二是stdout所重定向的文件如果不在/dev/shm,可能被锁。
2. 对于一些很短的停顿,比如取消偏向锁,打印的消耗比停顿本身还大。
3. 安全点日志是在安全点内打印的,本身加大了安全点的停顿时间。
所以安全日志应该只在问题排查时打开。
如果在生产系统上要打开,再再增加下面四个参数:
-XX:+UnlockDiagnosticVMOptions -XX: -DisplayVMOutput -XX:+LogVMOutput -XX:LogFile=/dev/shm/vm.log
打开Diagnostic(只是开放了更多的flag可选,不会主动激活某个flag),关掉输出VM日志到stdout,输出到独立文件,/dev/shm目录(内存文件系统)。\
偏向锁在Java 6和Java 7里是默认启用的,但是它在应用程序启动几秒钟之后才激活,如 有必要可以使用JVM参数来关闭延迟:-XX:BiasedLockingStartupDelay=0。如果你确定应用程 序里所有的锁通常情况下处于竞争状态,可以通过JVM参数关闭偏向锁:-XX:UseBiasedLocking=false,那么程序默认会进入轻量级锁状态
三.轻量级锁:
线程堆栈开放一个区域 :
锁记录(Lock Record)的空间,用于存储锁对象目前的Mark Word的拷贝 ,通过cas将对象的mark Word指向栈中,另一个
如果这个更新操作失败了,虚拟机首先会检查对象的Mark Word是否指向当前线程的栈帧,如果是就说明当前线程已经拥有了这个对象的锁,那就可以直接进入同步块继续执行。否则说明多个线程竞争锁,轻量级锁就要膨胀为重量级锁,锁标志的状态值变为“10”,Mark Word中存储的就是指向重量级锁(互斥量)的指针,后面等待锁的线程也要进入阻塞状态。 而当前线程便尝试使用自旋来获取锁,自旋就是为了不让线程阻塞,而采用循环去获取锁的过程。
轻量级锁是对锁竞争不是很激烈的情况。激烈了也就自旋很久就还是获取不到锁就变成重量级了。我是这样理解的
![](https://i-blog.csdnimg.cn/blog_migrate/8242a5363138a6d378fb5036a6777316.png)
(自旋锁):
等待轻量锁的线程不会阻塞,它会一直自旋等待锁,并如上所说修改markword。
这就是自旋锁,尝试获取锁的线程,在没有获得锁的时候,不被挂起,而转而去执行一个空循环,即自旋。在若干个自旋后,如果还没有获得锁,则才被挂起,获得锁,则执行代码
synchronized的执行过程:
1. 检测Mark Word里面是不是当前线程的ID,如果是,表示当前线程处于偏向锁
2. 如果不是,则使用CAS将当前线程的ID替换Mard Word,如果成功则表示当前线程获得偏向锁,置偏向标志位1
3. 如果失败,则说明发生竞争,撤销偏向锁,进而升级为轻量级锁。
4. 当前线程使用CAS将对象头的Mark Word替换为锁记录指针,如果成功,当前线程获得锁
5. 如果失败,表示其他线程竞争锁,当前线程便尝试使用自旋来获取锁。
6. 如果自旋成功则依然处于轻量级状态。
7. 如果自旋失败,则升级为重量级锁。
四.CAS
局限性:
1.ABA问题
2.循环时间长
3.只能对一个共享变量操作,可以使用两个变量合成一个变量来解决如:
AtomicReference类{
//
创建两个Person对象,它们的id分别是101和102。
Person p1 =
new Person(101
);、
Person p2 =
new Person(102
);
//
新建AtomicReference对象,初始化它的值为p1对象
AtomicReference ar =
new
AtomicReference(p1);
Person p3 =
(Person)ar.get();
}
五.java内存模型
1.Java的并发采用的是共享内存模型,Java线程之间的通信总是隐式进行,整个通信过程对 程序员完全透明。如果编写多线程程序的Java程序员不理解隐式进行的线程之间通信的工作 机制,很可能会遇到各种奇怪的内存可见性问题
2. 在Java中
,所有实例域、静态域和数组元素都存储在堆内存中,堆内存在线程之间共享 (本章用“共享变量”这个术语代指实例域,静态域和数组元素)。局部变量(Local Variables),方 法定义参数(Java语言规范称之为Formal Method Parameters)和异常处理器参数(Exception Handler Parameters)不会在线程之间共享,它们不会有内存可见性问题,也不受内存模型的影 响。
3. Java线程之间的通信由Java内存模型(本文简称为JMM)控制,JMM决定一个线程对共享 变量的写入何时对另一个线程可见。从抽象的角度来看,JMM定义了线程和主内存之间的抽 象关系:
线程之间的共享变量存储在主内存(Main Memory)中,每个线程都有一个私有的本地 内存(Local Memory),本地内存中存储了该线程以读/写共享变量的副本。本地内存是JMM的 一个抽象概念,并不真实存在。
4.与程序员密切相关的happen-before原则:
1.一个线程的每个操作,happen-before于该线程的任意后续操作;
2.一个线程的对共享资源的解锁 hp于其他线程对他的加锁;
3.一个voliate变量,写hp于读;
4.传递性,A hp B ,B hp C,则A hp C;
六 ReentrantLock
重入锁ReentrantLock,顾名思义,就是支持重进入的锁,它表示该锁能够支持一个线程对 资源的重复加锁。除此之外,该锁的还支持获取锁时的公平和非公平性选择。
ReentrantLock虽然没能像synchronized关键字一样支持隐式的重进入,但是在调用lock()方 法时,已经获取到锁的线程,能够再次调用lock()方法获取锁而不被阻塞。
-
在ReentrantLock中,调用lock()方法获取锁;调用unlock()方法释放锁。 ReentrantLock的实现依赖于Java同步器框架AbstractQueuedSynchronizer(本文简称之为 AQS)。AQS使用一个整型的volatile变量(命名为state)来维护同步状态,这 个volatile变量是ReentrantLock内存语义实现的关键
-
获取锁try nonfairTryAcquire (非公平锁/公平锁)ReentrantLock让线程1独占锁、线程2进入FIFO队列并阻塞
-
释放锁tryRelease方法:一个线程多次调用lock方法累加state是对应的,调用了多少次的lock()方法自然必须调用同样次数的unlock()方法才行,这样才把一个锁给全部解开。当一条线程对同一个ReentrantLock全部解锁之后,AQS的state自然就是0了,AbstractOwnableSynchronizer的exclusiveOwnerThread将被设置为null,这样就表示没有线程占有锁,方法返回true。
-
在公平的锁上,线程按照他们发出请求的顺序获取锁,但在非公平锁上,则允许‘插队’:当一个线程请求非公平锁时,如果在发出请求的同时该锁变成可用状态,那么这个线程会跳过队列中所有的等待线程而获得锁。
-
非公平的ReentrantLock 并不提倡 插队行为,但是无法防止某个线程在合适的时候进行插队(很容易出现 一个线程连续获取锁 )。 为什么会出现线程连续获取锁的情况呢?回顾nonfairTryAcquire(int acquires)方法,当一 个线程请求锁时,只要获取了同步状态即成功获取锁。在这个前提下,刚释放锁的线程再次获 取同步状态的几率会非常大,使得其他线程只能在同步队列中等待。 非公平性锁可能使线程“饥饿”, 为什么它又被设定成默认的实现呢 ?,如果把每次不同线程获取到锁定义为1次切换,公平性锁在测试中进行了10次切换,而非 公平性锁只有5次切换,这说明非公平性锁的开销更小。
-
-
在公平的锁中,如果有另一个线程持有锁或者有其他线程在等待队列中等待这个所,那么新发出的请求的线程将被放入到队列中。而非公平锁上,只有当锁被某个线程持有时,新发出请求的线程才会被放入队列中。
-
-
非公平锁性能高于公平锁性能的原因:
-
-
在恢复一个被挂起的线程与该线程真正运行之间存在着严重的延迟。
-
-
假设线程A持有一个锁,并且线程B请求这个锁。由于锁被A持有,因此B将被挂起。当A释放锁时,B将被唤醒,因此B会再次尝试获取这个锁。与此同时,如果线程C也请求这个锁,那么C很可能会在B被完全唤醒之前获得、使用以及释放这个锁。这样就是一种双赢的局面:B获得锁的时刻并没有推迟,C更早的获得了锁,并且吞吐量也提高了
-
当持有锁的时间相对较长或者请求锁的平均时间间隔较长,应该使用公平锁。在这些情况下,插队带来的吞吐量提升(当锁处于可用状态时,线程却还处于被唤醒的过程中)可能不会出现。
当 JVM 用 synchronized 管理锁定请求和释放时,JVM 在生成线程转储时能够包括锁定信息。这些对调试非常有价值,因为它们能标识死锁或者其他异常行为的来源。 Lock 类只是普通的类,JVM 不知道具体哪个线程拥有 Lock 对象
七.final变量:
写final域的重排序规则可以确保:在对象引用为任意线程可见之前,对象的final域已经被 正确初始化过了,而普通域不具有这个保障。在读线程B“看到”对象引用obj时, 很可能obj对象还没有构造完成(对普通域i的写操作被重排序到构造函数外,此时初始值1还 没有写入普通域i)。意思就是:有final常量的对象,必须在final初始化以后,其他对象才可以访问该字段。普通常量可能会存在访问时还没初始化为空的情况
final常量保证了在构造函数内初始化完之前赋值,其他参数(
构造器内的
)不保证。final常量不能构造函数内溢出,最好不要用this赋值
this逃逸:
为什么final引用不能从构造函数内“溢出,
final字段在没有溢出情况是线程安全的
写final域的重排序规则可以确保:在引用变量为任意线程可见之前,该 引用变量指向的对象的final域已经在构造函数中被正确初始化过了。
其实,要得到这个效果, 还需要一个保证:
在构造函数内部,不能让这个被构造对象的引用为其他线程所见,也就是对 象引用不能在构造函数中“逸出”
。
为了说明问题,让我们来看下面的示例代码。
public class FinalReferenceEscapeExample {
final int i;
static FinalReferenceEscapeExample obj;
public FinalReferenceEscapeExample () {
i = 1; // 1写final域
obj = this; // 2 this引用在此"逸出"
}
public static void writer() {
new FinalReferenceEscapeExample ();
}
public static void reader() {
if (obj != null) {
// 3 int temp = obj.i; // 4
}
}
}
图3-31 引用型final的执行时序图 假设一个线程A执行writer()方法,另一个线程B执行reader()方法。这里的操作2使得对象 还未完成构造前就为线程B可见。即使这里的操作2是构造函数的最后一步,且在程序中操作2 排在操 作1后面,执行read()方法的线程仍然可能无法看到final域被初始化后的值,因为这里的 操作1和操作2之间可能被重排序。
![](https://i-blog.csdnimg.cn/blog_migrate/3cb50018ae44fd5f74e56d3715b76612.png)
实际的执行时序可能如图3-32所示。 图3-32 多线程执行时序图 从图3-32可以看出:在构造函数返回前,被构造对象的引用不能为其他线程所见,因为此 时的final域可能还没有被初始化。在构造函数返回后,任意线程都将保证能看到final域正确初 始化之后的值。
八.Happen-before原则
-
程序次序规则:一个线程内,按照代码顺序,书写在前面的操作先行发生于书写在后面的操作;
-
锁定规则:一个unLock操作先行发生于后面对同一个锁额lock操作;
-
volatile变量规则:对一个变量的写操作先行发生于后面对这个变量的读操作;
-
传递规则:如果操作A先行发生于操作B,而操作B又先行发生于操作C,则可以得出操作A先行发生于操作C;
-
线程启动规则:Thread对象的start()方法先行发生于此线程的每个一个动作;
-
线程中断规则:对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生;
-
线程终结规则:线程中所有的操作都先行发生于线程的终止检测,我们可以通过Thread.join()方法结束、Thread.isAlive()的返回值手段检测到线程已经终止执行;
-
对象终结规则:一个对象的初始化完成先行发生于他的finalize()方法的开始;
上面八条是原生Java满足Happens-before关系的规则,但是我们可以对他们进行推导出其他满足happens-before的规则:
-
将一个元素放入一个线程安全的队列的操作Happens-Before从队列中取出这个元素的操作
-
将一个元素放入一个线程安全容器的操作Happens-Before从容器中取出这个元素的操作
-
在CountDownLatch上的倒数操作Happens-Before CountDownLatch#await()操作
-
释放Semaphore许可的操作Happens-Before获得许可操作
-
Future表示的任务的所有操作Happens-Before Future#get()操作
-
向Executor提交一个Runnable或Callable的操作Happens-Before任务开始执行操作
这里再说一遍happens-before的概念:如果两个操作不存在上述(前面8条 + 后面6条)任一一个happens-before规则,那么这两个操作就没有顺序的保障,JVM可以对这两个操作进行重排序。如果操作A happens-before操作B,那么操作A在内存上所做的操作对操作B都是可见的。
九。双重检查的单例模式问题及解决方式:
public class DoubleCheckedLocking { // 1
private static Instance instance; // 2
public static Instance getInstance() { // 3
if (instance == null) { // 4:第一次检查
synchronized (DoubleCheckedLocking.class) { // 5:加锁
if (instance == null) // 6:第二次检查
instance = new Instance(); // 7:问题的根源出在这里
} // 8
} // 9
return instance; // 10
} // 11
}
1.单线程下正常,多线程异常因为 ,第7步,存在三个步骤: 1.分配空间,2.初始化对象,3.intance指向对象。 2和3是会指令重排序的,所以可能导致:
memory = allocate(); // 1:分配对象的内存空间
instance = memory; // 3:设置instance指向刚分配的内存地址 // 注意,此时对象还没有被初始化!
ctorInstance(memory); // 2:初始化对象
2.解决方案:
(1).禁止2,3进行指令重排序,即
private static Instance instance 变量改为
private
voliate
static Instance instance
(2).使用静态内部类,因为类的加载是会被加锁的,只有一个线程初始化
一个类或接口类型T将被立即初始化的情况有
:
1)T是一个类,而且一个T类型的实例被创建。 2)T是一个类,且T中声明的一个静态方法被调用。 3)T中声明的一个静态字段被赋值。 4)T中声明的一个静态字段被使用,而且这个字段不是一个常量字段。
十.Java并发编程基础:
1.
Java将操作系统中的运行和就绪两个状态合并称为运行状态。阻塞状态是线程 阻塞在进入synchronized关键字修饰的方法或代码块(获取锁)时的状态,但是
阻塞在 java.concurrent包中Lock接口的线程状态却是等待状态
,因为java.concurrent包中Lock接口对于 阻塞的实现均使用了LockSupport类中的相关方法。
2.中断线程通过
interrupt()中断线程,Thread.
currentThread().isInterrupted()获取线程的中断标志位。
interrupt()不能中断在运行中的线程,它只能改变中断状态而已。
常用停止线程代码:
countThread.interrupt(); 中断线程;
private
static
class Runner
implements Runnable
{
private
long
i
@Override
public
void run()
{
try
{
while (!Thread.
currentThread().isInterrupted())
// 通过标志位isInterrupted默认是false,通过调用Thread.interrupt()置为true,
{
Thread.
sleep(100000);
// 中断的是这个线程
i++;
System.
out.println(
i);
}
}
catch (InterruptedException e){
//如果线程处于 wait(long)或wait(long, int)会让它进入等待(阻塞)状态,或者调
//用线程的join(), join(long), join(long, int), sleep(long),sleep(long,)
//需要通过catch异常中断线程 注意!:要在while循环外面
}
}
更加优雅的方式,通过一个参数来终止线程:
![](https://i-blog.csdnimg.cn/blog_migrate/3cb50018ae44fd5f74e56d3715b76612.png)
3.管道输入/输出流:
管道输入/输出流和普通的文件输入/输出流或者网络输入/输出流不同之处在于,它主要 用于线程之间的数据传输,而传输的媒介为内存。 管道输入/输出流主要包括了如下4种具体实现:
PipedOutputStream、PipedInputStream、 PipedReader和PipedWriter,前两种面向字节,而后两种面向字符 .
public static void main(String[] args) throws Exception {
PipedWriter out = new PipedWriter();
PipedReader in = new PipedReader(); // 将输出流和输入流进行连接,否则在使用时会抛出IOException out.connect(in);
Thread printThread = new Thread(new Print(in), "PrintThread");
printThread.start();
int receive = 0;
try { while ((receive = System.in.read()) != -1) {
out.write(receive);
}
} finally {
out.close();
}
}
4.
读写锁:
线程进入读锁的前提条件:
没有其他线程的写锁,
没有写请求或者有写请求,但调用线程和持有锁的线程是同一个
线程进入写锁的前提条件:
没有其他线程的读锁
没有其他线程的写锁
只需要在读操作时获取读锁,写操作时获取写锁即可。当写锁被获取到时,后续(非当前写 操作线程)的读写操作都会被阻塞,写锁释放之后,所有操作继续执行;
Java并发包提供读写锁的实现是
ReentrantReadWriteLock;
![](https://i-blog.csdnimg.cn/blog_migrate/8d2483faf93d0a53f9d8b75a7842508e.png)
![](https://i-blog.csdnimg.cn/blog_migrate/bba81b05dd8877aa343da0bdd4db1a78.png)
![](https://i-blog.csdnimg.cn/blog_migrate/c29b60a5a25d62a97492b0a09033eef3.png)
上述示例中,(有死锁危险)Cache组合一个非线程安全的HashMap作为缓存的实现,同时使用读写锁的 读锁和写锁来保证Cache是线程安全的。
在读操作get(String key)方法中,需要获取读锁,这使 得并发访问该方法时不会被阻塞。写操作put(String key,Object value)方法和clear()方法,在更新 HashMap时必须提前获取写锁,
当获取写锁后,其他线程对于读锁和写锁的获取均被阻塞,而 只有写锁被释放之后,其他读写操作才能继续。Cache使用读写锁提升读操作的并发性,也保 证每次写操作对所有的读写操作的可见性,同时简化了编程方式
![](https://i-blog.csdnimg.cn/blog_migrate/48f0bc5ca7126f543ade6b5633ee0918.png)
2.写锁的获取与释放
写锁是一个支持重进入的排它锁。如果当前线程已经获取了写锁,则增加写状态。如果当 前线程在获取写锁时,
读锁已经被获取(读状态不为0)
或者
该线程不是已经获取写锁的线程
, 则当前线程进入等待状态;
读锁是一个支持重进入的共享锁,它能够被多个线程同时获取,在没有其他写线程访问 (或者写状态为0)时,读锁总会被成功地获取,而所做的也只是(线程安全的)增加读状态
主要就是在一个线程内 ,先加写锁,然后释放写锁,如果在该方法后续还需要对公共资源进行使用,会查到其他线程对该线程数据的修改,所以需要在释放写锁之前增加读锁,这样。其他线程的写锁就阻塞;
5.
Condition接口(
需要提前获取到 Condition对象关联的锁
) 其实维护一个同步队列:
一个线程调用condition.await时
会释放锁
,将线程信息保存在队列的队伍,并且是等待状态
一个对象拥有一个同步队列和等待队列,而并发包中的 Lock(更确切地说是同步器)拥有一个同步队列和多个等待队列(也就是一个lock可以有多个condition)
当调用await后,当前线程等待,唤醒竞争锁等待队列中后继节点尝试获取锁;
调用Condition的signal()方法,将会唤醒在等待队列
中等待时间最长的节点(首节点)
,在 唤醒节点之前,会将节点移到同步队列(锁竞争队列)中
condition的await ,signal方法相当于 object的wait和notify方法:使用实例:
![](https://i-blog.csdnimg.cn/blog_migrate/00bab4f25c3f1a3720cf4d08e0619413.png)
最好是加上 变量,判断线程可能是因为 signal导致的唤醒 还是interrupt唤醒的,因为wait的线程在被中断后会抛出异常
public
void run()
{
while (!Thread.
currentThread().isInterrupted())
// 通过标志位isInterrupted默认是false,通过调用Thread.interrupt()置为true,
如果这个参数为true,说明返回线程的状态位true后需要置为false,所以会无限循环
{
try
//try需要放到while里面。因为只是一个线程中断标志,并不是真正中断,需要人为处理
{
Thread.
sleep(100);
// 中断的是这个线程
i++;
System.
out.println(
i);
}
catch (InterruptedException e)
{
System.
out.println(Thread.
currentThread().getName() +
" (" + Thread.
currentThread().getState()
+
") catch InterruptedException.");
}
}
System.
out.println(
"Count i = " +
i);
}
该方法在另一个线程调用interrupt()方法会中止sleep,继续执行while语句
有界队列是一种特殊的队列,当队列为空时,队列的获取操作 将会阻塞获取线程,直到队列中有新增元素,当队列已满时,队列的插入操作将会阻塞插入线 程,直到队列出现“空位”
十二.
Java并发容器和框架
1.
ConcurrentHashMap的实现原理与使用(初始化大小为16):
![](https://i-blog.csdnimg.cn/blog_migrate/81d959c9a2fcbcaa24bac7d72fc74421.png)
get操作:
Segment的get操作实现非常简单和高效。
先经过一次再散列
,然后
使用这个散列值通过散 列运算定位到Segment
,再通过
散列算法定位到元素;
get操作的高效之处在于
整个get过程不需要加锁
,除非读到的值是空才会加锁重读:
原因是它的get方法里将要使用的共享变量都定义成volatile类型,如用于统计当前 Segement大小的count字段和用于存储值的HashEntry的value。定义成volatile的变量,能够在线 程之间保持可见性,能够被多线程同时读,并且保证不会读到过期的值,但是只能被单线程写
put操作:
由于put方法里需要对共享变量进行写入操作,所以为了线程安全,在操作共享变量时必 须加锁。put方法首先定位到Segment,然后在Segment里进行插入操作。
插入操作需要经历两个 步骤,第一步判断是否需要对Segment里的HashEntry数组进行扩容,第二步定位添加元素的位 置,然后将其放在HashEntry数组里。
(1)是否需要扩容 在插入元素前会先判断Segment里的HashEntry数组是否超过容量(threshold),如果超过阈 值,则对数组进行扩容。值得一提的是,Segment的扩容判断比HashMap更恰当,因为HashMap 是在插入元素后判断元素是否已经到达容量的,如果到达了就进行扩容,但是很有可能扩容 之后没有新元素插入,这时HashMap就进行了一次无效的扩容。
(2)如何扩容 在扩容的时候,
首先会创建一个容量是原来容量两倍的数组
,然后将原数组里的元素进 行再散列后插入到新的数组里。为了高效,ConcurrentHashMap不会对整个容器进行扩容,
而只 对某个segment进行扩容。
size操作:
ConcurrentHashMap的做法是先尝试2次通过不锁住Segment的方式来统计各个Segment大小,如 果统计的过程中,容器的count发生了变化,则再采用加锁的方式来统计所有Segment的大小。
2.concurrentLinkQueue:
使用cas实现的线程安全的先进先出的队列;
3.阻塞队列:
阻塞队列(BlockingQueue)是一个支持两个附加操作的队列。这两个附加的操作支持阻塞 的插入和移除方法。
1)支持阻塞的插入方法:意思是当队列满时,队列会阻塞插入元素的线程,直到队列不 满。
2)支持阻塞的移除方法:意思是在队列为空时,获取元素的线程会等待队列变为非空。
阻塞队列常用于生产者和消费者的场景,生产者是向队列里添加元素的线程,消费者是 从队列里取元素的线程。阻塞队列就是生产者用来存放元素、消费者用来获取元素的容器
![](https://i-blog.csdnimg.cn/blog_migrate/bbc1b09259acd7ad9bac2052c5e7acaa.png)
4.
Fork/Join框架:
Fork/Join框架是Java 7提供的一个用于并行执行任务的框架,是一个把大任务分割成若干 个小任务,最终汇总每个小任务结果后得到大任务结果的框架:
步骤1 分割任务。首先我们需要有一个fork类来把大任务分割成子任务,有可能子任务还 是很大,所以还需要不停地分割,直到分割出的子任务足够小。
步骤2 执行任务并合并结果。分割的子任务分别放在双端队列里,然后几个启动线程分 别从双端队列里获取任务执行。子任务执行完的结果都统一放在一个队列里,启动一个线程 从队列里拿数据,然后合并这些数据。
Fork/Join使用两个类来完成以上两件事情。
①ForkJoinTask:我们要使用ForkJoin框架,必须首先创建一个ForkJoin任务。它提供在任务 中执行fork()和join()操作的机制。通常情况下,我们不需要直接继承ForkJoinTask类,只需要继 承它的子类,Fork/Join框架提供了以下两个子类。 ·RecursiveAction:用于没有返回结果的任务。 ·RecursiveTask:用于有返回结果的任务。
②ForkJoinPool:ForkJoinTask需要通过ForkJoinPool来执行。 任务分割出的子任务会添加到当前工作线程所维护的双端队列中,进入队列的头部。当 一个工作线程的队列里暂时没有任务时,它会随机从其他工作线程的队列的尾部获取一个任 务
让我们通过一个简单的需求来使用Fork/Join框架,
需求是:计算1+2+3+4的结果。
使用Fork/Join框架首先要考虑到的是如何分割任务,如果希望每个子任务最多执行两个 数的相加,那么我们设置分割的阈值是2,由于是4个数字相加,所以Fork/Join框架会把这个任 务fork成两个子任务,子任务一负责计算1+2,子任务二负责计算3+4,然后再join两个子任务 的结果。因为是有结果的任务,所以必须继承RecursiveTask,实现代码如下。
public
class
CountTask
extends RecursiveTask<Integer>
{
private
static
final
int
THRESHOLD = 2;
// 阈值
private
int
start;
private
int
end;
public CountTask(
int start,
int end)
{
this.
start = start;
this.
end = end;
}
@Override
protected Integer compute()
{
int sum = 0;
// 如果任务足够小就计算任务
boolean canCompute = (
end -
start) <=
THRESHOLD;
if (canCompute)
{
for (
int i =
start; i <=
end; i++)
{
sum += i;
}
}
else
{
// 如果任务大于阈值,就分裂成两个子任务计算
int middle = (
start +
end) / 2;
CountTask leftTask =
new CountTask(
start, middle);
CountTask rightTask =
new CountTask(middle + 1,
end);
// 执行子任务
leftTask.fork();
rightTask.fork();
// 等待子任务执行完,并得到其结果
int leftResult = leftTask.join();
int rightResult = rightTask.join();
// 合并子任务
sum = leftResult + rightResult;
}
return sum;
}
public
static
void main(String[] args)
{
ForkJoinPool forkJoinPool =
new ForkJoinPool();
// 生成一个计算任务,负责计算1+2+3+4
CountTask task =
new CountTask(1, 4);
// 执行一个任务
Future<Integer> result = forkJoinPool.submit(task);
try
{
System.
out.println(result.get());
}
catch (InterruptedException e)
{
}
catch (ExecutionException e)
{
}
}
}
通过这个例子,我们进一步了解ForkJoinTask
,ForkJoinTask与一般任务的主要区别在于它 需要实现compute方法,在这个方法里,首先需要判断任务是否足够小,如果足够小就直接执 行任务。如果不足够小,就必须分割成两个子任务,每个子任务在调用fork方法时,又会进入 compute方法,看看当前子任务是否需要继续分割成子任务,如果不需要继续分割,则执行当 前子任务并返回结果。使用join方法会等待子任务执行完并得到其结果
ForkJoinTask在执行的时候可能会抛出异常,但是我们没办法在主线程里直接捕获异常, 所
以ForkJoinTask提供了isCompletedAbnormally()方法来检查任务是否已经抛出异常或已经被 取消了,并且可以通过ForkJoinTask的getException方法获取异常。使用如下代码。
if(task.isCompletedAbnormally()) { System.out.println(task.getException()); }
getException方法返回Throwable对象,如果任务被取消了则返回CancellationException。如 果任务没有完成或者没有抛出异常则返回null
5.
Java中的13个原子操作类:
【·AtomicBoolean:原子更新布尔类型。
·AtomicInteger:原子更新整型。
·AtomicLong:原子更新长整型 。
】
【
·AtomicIntegerArray:原子更新整型数组里的元素。
例子:
public class AtomicIntegerArrayTest {
static int[] value = new int[] { 1, 2 };
static AtomicIntegerArray ai = new AtomicIntegerArray(value);
public static void main(String[] args) {
ai.getAndSet(0, 3);
System.out.println(ai.get(0));
System.out.println(value[0]);
} //结果 3 1
}
·AtomicLongArray:原子更新长整型数组里的元素。
】
【
·AtomicReferenceArray:原子更新引用类型数组里的元素。
·AtomicReference:原子更新引用类型。
·AtomicReferenceFieldUpdater:原子更新引用类型里的字段。
·AtomicMarkableReference:原子更新带有标记位的引用类型。可以原子更新一个布尔类 型的标记位和引用类型。构造方法是AtomicMarkableReference(V initialRef,boolean initialMark)
】
例子:
public
class
AtomicReferenceTest {
public
static
AtomicReference<
user>
atomicUserRef =
new AtomicReference<user>();
public
static
void main(String[] args) {
User user =
new
User(
"conan"
, 15);
atomicUserRef.set(user);
User updateUser =
new
User(
"Shinichi"
, 17);
atomicUserRef.compareAndSet(user
,
updateUser);
System.out.println(
atomicUserRef.get().getName());
System.out.println(
atomicUserRef.get().getOld());
}
}
如果需原子地更新某个类里的某个字段时,就需要使用原子更新字段类,Atomic包提供 了以下3个类进行原子字段更新。
·AtomicIntegerFieldUpdater:原子更新整型的字段的更新器。
·AtomicLongFieldUpdater:原子更新长整型字段的更新器。
·AtomicStampedReference:原子更新带有版本号的引用类型。该类将整数值与引用关联起 来,可用于原子的更新数据和数据的版本号,可以解决使用CAS进行原子更新时可能出现的 ABA问题。
// 创建原子更新器,并设置需要更新的对象类和对象的属性
private static AtomicIntegerFieldUpdater a = AtomicIntegerFieldUpdater. newUpdater(User.class, "old");
public static void main(String[] args) {
// 设置柯南的年龄是10岁
User conan = new User("conan", 10); // 柯南长了一岁,但是仍然会输出旧的年龄
System.out.println(a.getAndIncrement(conan)); // 输出柯南现在的年龄
System.out.println(a.get(conan));
}
6.线程池:
·ArrayBlockingQueue:是一个基于数组结构的有界阻塞队列,此队列按FIFO(先进先出)原 则对元素进行排序。
·LinkedBlockingQueue:一个基于链表结构的阻塞队列,此队列按FIFO排序元素,吞吐量通 常要高于ArrayBlockingQueue。静态工厂方法Executors.newFixedThreadPool()使用了这个队列。
·SynchronousQueue:一个不存储元素的阻塞队列。每个插入操作必须等到另一个线程调用 移除操作,否则插入操作一直处于阻塞状态,吞吐量通常要高于Linked-BlockingQueue,静态工 厂方法 Executors.newCachedThreadPool使用了这个队列。
·PriorityBlockingQueue:一个具有优先级的无限阻塞队列。
(1)可以使用两个方法向线程池提交任务,分别为execute()和submit()方法
(2)execute()方法不返回任务信息,并不知道线程成功与否,
(3) submit可以返回future查看结果:
future对象可以判断任务是否执行成功,并且可以通过future的get()方法来获取返回值,get()方 法会阻塞当前线程直到任务完成,而使用get(long timeout,TimeUnit unit)方法则会阻塞当前线 程一段时间后立 即返回,这时候有可能任务没有执行完
(4)
要想合理地配置线程池,就必须首先分析任务特性,可以从以下几个角度来分析。
·任务的性质:CPU密集型任务、IO密集型任务和混合型任务。
·任务的优先级:高、中和低。
·任务的执行时间:长、中和短。
·任务的依赖性:是否依赖其他系统资源,如数据库连接。
性质不同的任务可以用不同规模的线程池分开处理。
CPU密集型任务应配置尽可能小的 线程,如配置Ncpu+1个线程的线程池。由于IO密集型任务线程并不是一直在执行任务,则应配 置尽可能多的线程,如2*Ncpu。混合型的任务,如果可以拆分,将其拆 分成一个CPU密集型任务 和一个IO密集型任务
,
只要这两个任务执行的时间相差不是太大,那么分解后执行的吞吐量 将高于串行执行的吞吐量。如果这两个任务执行时间相差太大,则没必要进行分解。
(5)
优先级不同的任务可以使用优先级队列PriorityBlockingQueue来处理。它可以让优先级高 的任务先执行。
注意 如果一直有优先级高的任务提交到队列里,那么优先级低的任务可能永远不能 执行。
执行时间不同的任务可以交给不同规模的线程池来处理,或者可以使用优先级队列,让 执行时间短的任务先执行。
依赖数据库连接池的任务,因为线程提交SQL后需要等待数据库返回结果,等待的时间越 长,则CPU空闲时间就越长,那么线程数应该设置得越大,这样才能更好地利用CPU。 建议使用有界队列
7.
Executor框架的成员:
ThreadPoolExecutor、ScheduledThreadPoolExecutor、 Future接口、Runnable接口、Callable接口和Executors;
(1)ThreadPoolExecutor ThreadPoolExecutor通常使用工厂类Executors来创建。Executors可以创建3种类型的 ThreadPoolExecutor:SingleThreadExecutor、FixedThreadPool和CachedThreadPool。
CachedThreadPool是大小无界的线程池,适用于执行很多的短期异步任务的小程序,或者 是负载较轻的服务器。
(2)ScheduledThreadPoolExecutor ScheduledThreadPoolExecutor通常使用工厂类Executors来创建。
Executors可以创建2种类 型的ScheduledThreadPoolExecutor,如下。
·ScheduledThreadPoolExecutor。包含若干个线程的ScheduledThreadPoolExecutor。 (可以实现定时任务,循环使用。比time更高效)
·SingleThreadScheduledExecutor。只包含一个线 程的ScheduledThreadPoolExecutor
ScheduledThreadPoolExecutor继承自ThreadPoolExecutor。它主要用来在给定的延迟之后运 行任务,或者定期执行任务。ScheduledThreadPoolExecutor的功能与Timer类似,但 ScheduledThreadPoolExecutor 功能更强大、更灵活。Timer对应的是单个后台线程,而 ScheduledThreadPoolExecutor可以在构造函数中指定多个对应的后台线程数。
(3)FixedThreadPool和SingleThreadExecutor
使用无界队列
LinkedBlockingQueue作为线程池的 工作队列。CachedThreadPool使用没有容量的SynchronousQueue作为线程池的工作队列,但 CachedThreadPool的 maximumPool是无界的。这意味着,如果主线程提交任务的速度高于 maximumPool中线程处理任务的速度时,CachedThreadPool会不断创建新线程。极端情况下, CachedThreadPool会因为创建过多线程而耗尽CPU 和内存资源