JUC多线程编程
1.多线程编程步骤
第一步 创建资源类,在资源类创建属性和操作方法
第二步 在资源类操作方法
- (1).判断
- (2).干活
- (3).通知
第三步 创建多个线程,调用资源类的操作方法
第四步 防止虚假唤醒问题
2.线程间通信
例子:有两个线程,实现对一个初始值是0的变量(线程间通信),进行有序的+1、-1操作,效果:a 1 b 0 ; a 1 b 0 ; a 1 b 0
代码实现1(synchronized):
Ⅰ.创建资源类,在资源类创建属性和操作方法
//第一步 创建资源类 定义属性和操作方法
class Share {
//初始值
private int number = 0;
//+1的方法
public synchronized void incr() throws InterruptedException {
//第二步 判断 干活 通知
while (number != 0) {
this.wait(); //在哪里睡就在哪里醒,使用循环判断防止虚假唤醒问题
}
number++;
System.out.println(Thread.currentThread().getName() + "::" + number);
//通知其他线程
this.notifyAll();
}
//-1的方法
public synchronized void decr() throws InterruptedException {
//第二步 判断 干活 通知
while (number != 1) {
this.wait(); //在哪里睡就在哪里醒,使用循环判断防止虚假唤醒问题
}
number--;
System.out.println(Thread.currentThread().getName() + "::" + number);
//通知其他线程
this.notifyAll();
}
}
Ⅱ.创建多个线程,调用资源类的操作方法
public class ThreadDemo1 {
public static void main(String[] args) {
//第三步 创建多个线程 调用资源类的操作方法
Share share = new Share();
new Thread(() -> {
for (int i = 0; i < 10; i++) {
try {
share.incr(); //+1
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}, "AA").start();
new Thread(() -> {
for (int i = 0; i < 10; i++) {
try {
share.decr(); //-1
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}, "BB").start();
new Thread(() -> {
for (int i = 0; i < 10; i++) {
try {
share.incr(); //+1
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}, "CC").start();
new Thread(() -> {
for (int i = 0; i < 10; i++) {
try {
share.decr(); //-1
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}, "DD").start();
}
}
代码实现2(Lock实现):
//第一步 创建资源类 定义属性和操作方法
class Share {
//初始值
private int number = 0;
//创建Lock
private Lock lock = new ReentrantLock();
private Condition condition = lock.newCondition();
//+1
public void incr() throws InterruptedException {
try {
//上锁
lock.lock();
//第二步 判断 干活 通知
while (number != 0) {
condition.await(); //在哪睡在哪醒,使用循环判断防止虚假唤醒
}
number++;
System.out.println(Thread.currentThread().getName() + "::" + number);
condition.signalAll();//通知其他线程
} finally {
//释放锁
lock.unlock();
}
}
//-1
public void decr() throws InterruptedException {
try {
lock.lock();
//第二步 判断 干活 通知
while (number != 1) {
condition.await(); //在哪睡在哪醒,使用循环判断防止虚假唤醒
}
number--;
System.out.println(Thread.currentThread().getName() + "::" + number);
condition.signalAll();//通知其他线程
} finally {
//释放锁
lock.unlock();
}
}
}
public class ThreadDemo2 {
public static void main(String[] args) {
//第三步 创建多个线程 调用资源类的操作方法
Share share = new Share();
new Thread(() -> {
for (int i = 0; i < 10; i++) {
try {
share.incr(); //+1
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}, "AA").start();
new Thread(() -> {
for (int i = 0; i < 10; i++) {
try {
share.decr(); //-1
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}, "BB").start();
new Thread(() -> {
for (int i = 0; i < 10; i++) {
try {
share.incr(); //+1
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}, "CC").start();
new Thread(() -> {
for (int i = 0; i < 10; i++) {
try {
share.decr(); //-1
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}, "DD").start();
}
}
3.线程间定制化通信
例子:启动三个线程,按照如下要求输出10轮:AA打印1次,BB打印2次,CC打印3次
代码实现(Lock):
//第一步 创建资源类 定义属性和操作方法
class ShareResource {
//定义标志位
private int flag = 1; // 1 AA ; 2 BB ; 3 CC
//创建Lock锁
private Lock lock = new ReentrantLock();
//创建三个condition
private Condition c1 = lock.newCondition();
private Condition c2 = lock.newCondition();
private Condition c3 = lock.newCondition();
//第二步 创建操作方法
/**
* 打印1次,参数第几轮
*
* @param loop
*/
public void print1(int loop) throws InterruptedException {
//上锁
lock.lock();
try {
// 判断 干活 通知
while (flag != 1) {
c1.await(); //等待
}
for (int i = 0; i < 1; i++) {
System.out.println(Thread.currentThread().getName() + "::" + i + ":轮数:" + loop);
}
flag = 2; //修改标志位
c2.signal(); //通知BB线程
} finally {
//释放锁
lock.unlock();
}
}
/**
* 打印2次,参数第几轮
*
* @param loop
*/
public void print2(int loop) throws InterruptedException {
//上锁
lock.lock();
try {
//判断 干活 通知
while (flag != 2) {
c2.await(); //等待
}
for (int i = 0; i < 2; i++) {
System.out.println(Thread.currentThread().getName() + "::" + i + ":轮数:" + loop);
}
flag = 3; //修改标志位
c3.signal(); //通知CC线程
} finally {
//释放锁
lock.unlock();
}
}
/**
* 打印3次,参数第几轮
*
* @param loop
*/
public void print3(int loop) throws InterruptedException {
//上锁
lock.lock();
try {
//判断 干活 通知
while (flag != 3) {
c3.await(); //等待
}
for (int i = 0; i < 3; i++) {
System.out.println(Thread.currentThread().getName() + "::" + i + ":轮数:" + loop);
}
flag = 1; //修改标志位
c1.signal(); //通知AA线程
} finally {
//释放锁
lock.unlock();
}
}
}
public class ThreadDemo3 {
public static void main(String[] args) {
//第三步 创建多个线程 调用操作方法
ShareResource shareResource = new ShareResource();
new Thread(() -> {
for (int i = 1; i <= 10; i++) {
try {
shareResource.print1(i);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}, "AA").start();
new Thread(() -> {
for (int i = 1; i <= 10; i++) {
try {
shareResource.print2(i);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}, "BB").start();
new Thread(() -> {
for (int i = 1; i <= 10; i++) {
try {
shareResource.print3(i);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}, "CC").start();
}
}
4.谈谈Volatile
Volatile是Java虚拟机提供的轻量级
的同步机制(三大特性)
- 保证可见性
- 不保证原子性
- 禁止指令重排
4.1.JMM内存模型的可见性
4.1.1.JMM是什么
JMM是Java内存模型,也就是Java Memory Model,简称JMM,本身是一种抽象的概念,实际上并不存在,它描述的是一组规则或规范,通过这组规范定义了程序中各个变量(包括实例字段,静态字段和构成数组对象的元素)的访问方式
JMM关于同步的规定:
- 线程解锁前,必须把共享变量的值刷新回主内存
- 线程加锁前,必须读取主内存的最新值,到自己的工作内存
- 加锁和解锁是同一把锁
由于JVM运行程序的实体是线程,而每个线程创建时JVM都会为其创建一个工作内存(有些地方称为栈空间),工作内存是每个线程的私有数据区域,而Java内存模型中规定所有变量都存储在主内存,主内存是共享内存区域,所有线程都可以访问,但线程对变量的操作(读取赋值等)必须在工作内存中进行,首先要将变量从主内存拷贝到自己的工作内存空间,然后对变量进行操作,操作完成后再将变量写回主内存,不能直接操作主内存中的变量,各个线程中的工作内存中存储着主内存中的变量副本拷贝,因此不同的线程间无法访问对方的工作内存,线程间的通信(传值)必须通过主内存来完成,其简要访问过程:
数据传输速率:硬盘 < 内存 < < cache < CPU
上面提到了两个概念:主内存 和 工作内存
- 主内存:就是计算机的内存,也就是经常提到的8G内存,16G内存
- 工作内存:我们实例化 new student,那么 age = 25 也是存储在主内存中;当有三个线程同时访问 student中的age变量时,那么每个线程都会拷贝一份,到各自的工作内存,从而实现了变量的拷贝
即:JMM内存模型的可见性,指的是当主内存区域中的值被某个线程写入更改后,其它线程会马上知晓更改后的值,并重新得到更改后的值。
MESI(缓存一致性协议):
当CPU写数据时,如果发现操作的变量是共享变量,即在其它CPU中也存在该变量的副本,会发出信号通知其它CPU将该内存变量的缓存行设置为无效,因此当其它CPU读取这个变量时,发现自己缓存该变量的缓存行是无效的,那么它就会从内存中重新读取。
那么是如何发现数据是否失效呢?
这里是用到了总线嗅探技术,就是每个处理器通过嗅探在总线上传播的数据来检查自己缓存值是否过期了,当处理器发现自己的缓存行对应的内存地址被修改,就会将当前处理器的缓存行设置为无效状态,当处理器对这个数据进行修改操作的时候,会重新从内存中把数据读取到处理器缓存中。
总线嗅探技术有哪些缺点?
总线风暴:
由于volatile的MESI缓存一致性协议,需要不断的从主内存嗅探和CAS循环,无效的交互会导致总线带宽达到峰值。因此不要大量使用volatile关键字,至于什么时候使用volatile、什么时候用锁以及syschonized都是需要根据实际场景的。
4.1.2.可见性代码验证
1).对于成员变量没有添加任何修饰时,是无法感知其它线程修改后的值
/**
* 假设是主物理内存
*/
class MyData {
int number = 0;
public void addTo60() {
this.number = 60;
}
}
/**
* 验证volatile的可见性
* 1.假设int number = 0, number变量之前没有添加volatile关键字修饰
*/
public class VolatileDemo {
public static void main(String[] args) {
MyData myData = new MyData();//资源类
new Thread(() -> {
System.out.println(Thread.currentThread().getName() + "\t come in");
try {
TimeUnit.SECONDS.sleep(3); //线程睡眠3秒,假设在进行运算
} catch (InterruptedException e) {
e.printStackTrace();
}
myData.addTo60();//修改number的值
System.out.println(Thread.currentThread().getName() + "\t update number value:" + myData.number);
}, "AAA").start();
while (myData.number == 0) {
//main线程就一直在这里等待循环,直到number的值不等于零
}
// 按道理这个值是不可能打印出来的,因为主线程运行的时候,number的值为0,所以一直在循环
// 如果能输出这句话,说明AAA线程在睡眠3秒后,更新的number的值,重新写入到主内存,并被main线程感知到了
System.out.println(Thread.currentThread().getName() + "\t mission is over");
/**
* 最后输出结果:
* AAA come in
* AAA update number value:60
* 最后线程没有停止,并行没有输出 mission is over 这句话,说明没有用volatile修饰的变量,是没有可见性
*/
}
}
输出结果w为:
2).当我们修改MyData类中的成员变量时,并且添加volatile关键字修饰
/**
* 假设是主物理内存
*/
class MyData {
/**
* volatile 修饰的关键字,是为了增加 主线程和线程之间的可见性,只要有一个线程修改了内存中的值,其它线程也能马上感知
*/
volatile int number = 0;
public void addTo60() {
this.number = 60;
}
}
最后输出的结果为:
主线程也执行完毕了,说明volatile修饰的变量,是具备JVM轻量级同步机制的,能够感知其它线程的修改后的值。
4.2.Volatile不保证原子性
原子性:不可分割,完整性,也就是说某个线程正在做某个具体业务时,中间不可以被加塞或者被分割,需要具体完成,要么同时成功,要么同时失败。
4.2.1.验证volatile不保证原子性
为了测试volatile是否保证原子性,我们创建了20个线程,然后每个线程分别循环1000次,来调用number++的方法
/**
* 假设是主物理内存
*/
class MyData {
/**
* volatile 修饰的关键字,是为了增加 主线程和线程之间的可见性,只要有一个线程修改了内存中的值,其它线程也能马上感知
*/
volatile int number = 0;
public void addTo60() {
this.number = 60;
}
public void addPlusPlus() {
number++;
}
}
/**
* 验证volatile的可见性
* 1.假设int number = 0, number变量之前没有添加volatile关键字修饰
* 2.添加volatile,可以解决可见性问题
*
* 验证volatile不保证原子性
* 1.原子性指的是什么意思?
*/
public class VolatileDemo {
public static void main(String[] args) {
MyData myData = new MyData();
//创建20个线程,线程里面进行1000次循环
for (int i = 0; i < 20; i++) {
new Thread(() -> {
for (int j = 0; j < 1000; j++) {
myData.addPlusPlus();
}
}, String.valueOf(i)).start();
}
// 需要等待上面20个线程都计算完成后,在用main线程取得最终的结果值
// 这里判断线程数是否大于2,为什么是2?因为默认是有两个线程的,一个main线程,一个gc线程
while (Thread.activeCount() > 2) {
Thread.yield();//yield表示不执行
}
// 查看最终的值,假设volatile保证原子性,那么输出的值应该为: 20 * 1000 = 20000
System.out.println(Thread.currentThread().getName() + "\t finally number value:" + myData.number);
}
}
第一次运行结果:
第二次运行结果:
第三次运行结果:
最终结果我们会发现,number输出的值并没有20000,而且是每次运行的结果都不一致的,这说明了volatile修饰的变量不保证原子性
4.2.2.为什么出现数值丢失
各自线程在写入主内存的时候,出现了数据的丢失,而引起的数值缺失的问题
将一个简单的number++操作,转换为字节码文件一探究竟
public class T1 {
volatile int n = 0;
public void add() {
n++;
}
}
转换后的字节码文件:
public class jmm.T1 {
volatile int n;
public jmm.T1();
Code:
0: aload_0
1: invokespecial #1 // Method java/lang/Object."":()V
4: aload_0
5: iconst_0
6: putfield #2 // Field n:I
9: return
public void add();
Code:
0: aload_0
1: dup
2: getfield #2 // Field n:I
5: iconst_1
6: iadd
7: putfield #2 // Field n:I
10: return
}
我们能够发现 n++这条命令,被拆分成了3个指令
- 执行
getfield
从主内存拿到原始n - 执行
iadd
进行加1操作 - 执行
putfileld
把累加后的值写回主内存
假设我们没有加synchronized
那么第一步就可能存在着,三个线程同时通过getfield
命令,拿到主存中的n值,然后三个线程,各自在自己的工作内存中进行加1操作,但他们并发进行iadd
命令的时候,因为只能一个进行写,所以其它操作会被挂起,假设1线程,先进行了写操作,在写完后,volatile的可见性,应该需要告诉其它两个线程,主内存的值已经被修改了,但是因为太快了,其它两个线程,陆续执行iadd
命令,进行写入操作,这就造成了其他线程没有接受到主内存n的改变,从而覆盖了原来的值,出现写丢失,这样也就让最终的结果少于20000
4.2.3.如何解决
在多线程环境下number++
在多线程环境下是非线程安全的,解决的方法有哪些呢
1.在方法上加入synchronized
public synchronized void addPlusPlus() {
number++;
}
运行结果:
发现引入synchronized
关键字后,保证了该方法每次只能够一个线程进行访问和操作,最终输出的结果也就为20000。
上面的方法引入synchronized
,虽然能够保证原子性,但是为了解决number++
,而引入重量级的同步机制,有种杀鸡焉用牛刀
,除了引用synchronized关键字外,还可以使用JUC下面的原子包装类,即刚刚的int类型的number,可以使用AtomicInteger来代替
2.使用JUC原子包装类AtomicInteger
/**
* 创建一个原子Integer包装类,默认为0
*/
AtomicInteger atomicInteger = new AtomicInteger();
public void addAtomic() {
//相当于atomicInteger++
atomicInteger.getAndIncrement();
}
然后同理,继续刚刚的操作
// 创建20个线程,线程里面进行1000次循环
for (int i = 0; i < 20; i++) {
new Thread(() -> {
for (int j = 0; j < 1000; j++) {
myData.addPlusPlus();
myData.addAtomic();
}
}, String.valueOf(i)).start();
}
while (Thread.activeCount() > 2) {
Thread.yield();//yield表示不执行
}
System.out.println(Thread.currentThread().getName() + "\t finally number value: " + myData.number);
System.out.println(Thread.currentThread().getName() + "\t finally atomicNumber value: " + myData.atomicInteger);
运行结果,一个是引入synchronized,一个是使用原子包装类AtomicInteger
4.3.Volatile禁止指令重排
计算机在执行程序时,为了提高性能,编译器和处理器常常会对指令重排,一般分为以下三种:
源代码 -> 编译器优化的重排 -> 指令并行的重排 -> 内存系统的重排 -> 最终执行指令
单线程环境里面确保最终执行结果和代码顺序的结果一致
处理器在进行重排序时,必须要考虑指令之间的数据依赖性
多线程环境中线程交替执行,由于编译器优化重排的存在,两个线程中使用的变量能否保证一致性是无法确定的,结果无法预测。
4.3.1.指令重排 - example 1
public void mySort() {
int x = 11;
int y = 12;
x = x + 5;
y = x * x;
}
按照正常单线程环境,执行顺序是 1 2 3 4,但是在多线程环境下,可能出现以下的顺序:
- 2 1 3 4
- 1 3 2 4
上述的过程就可以当做是指令的重排,即内部执行顺序,和我们的代码顺序不一样
但是指令重排也是有限制的,即不会出现下面的顺序
- 4 3 2 1
因为处理器在进行重排时候,必须考虑到指令之间的数据依赖性
因为步骤 4:需要依赖于 y的申明,以及x的申明,故因为存在数据依赖,无法首先执行
例子:
int a,b,x,y=0
线程1 | 线程2 |
---|---|
x = a; | y = b; |
b = 1; | a = 2; |
x = 0; y = 0 |
因为上面的代码,不存在数据的依赖性,因此编译器可能对数据进行重排
线程1 | 线程2 |
---|---|
b = 1; | a = 2; |
x = a; | y = b; |
x = 2; y = 1; |
这样造成的结果,和最开始的就不一致了,这就是导致重排后,结果和最开始的不一样,因此为了防止这种结果出现,volatile就规定禁止指令重排,为了保证数据的一致性
4.3.2.指令重排 - example 2
比如下面这段代码:
public class ResortSeqDemo {
int a = 0;
boolean flag = false;
public void method01() {
a = 1;
flag = true;
}
public void method02() {
if (flag) {
a = a + 5;
System.out.println("retValue:" + a);
}
}
}
我们按照正常的顺序,分别调用method01() 和 method02() 那么,最终输出就是 a = 6
但是如果在多线程环境下,因为方法1 和 方法2,他们之间不能存在数据依赖的问题,因此原先的顺序可能是
a = 1;
flag = true;
a = a + 5;
System.out.println("reValue:" + a);
但是在经过编译器,指令,或者内存的重排后,可能会出现这样的情况
flag = true;
a = a + 5;
System.out.println("reValue:" + a);
a = 1;
也就是先执行 flag = true后,另外一个线程马上调用方法2,满足 flag的判断,最终让a + 5,结果为5,这样同样出现了数据不一致的问题
为什么会出现这个结果:多线程环境中线程交替执行,由于编译器优化重排的存在,两个线程中使用的变量能否保证一致性是无法确定的,结果无法预测。
这样就需要通过volatile来修饰,来保证线程安全性
4.3.3.Volatile针对指令重排做了啥
Volatile实现禁止指令重排优化,从而避免了多线程环境下程序出现乱序执行的现象
首先了解一个概念,内存屏障(Memory Barrier)又称内存栅栏,是一个CPU指令,它的作用有两个:
- 保证特定操作的顺序
- 保证某些变量的内存可见性(利用该特性实现volatile的内存可见性)
由于编译器和处理器都能执行指令重排的优化,如果在指令间插入一条Memory Barrier则会告诉编译器和CPU,不管什么指令都不能和这条Memory Barrier指令重排序,也就是说通过插入内存屏障禁止在内存屏障前后的指令执行重排序优化
。 内存屏障另外一个作用是刷新出各种CPU的缓存数,因此任何CPU上的线程都能读取到这些数据的最新版本。
也就是过在Volatile的写 和 读的时候,加入屏障,防止出现指令重排的
4.3.4.线程安全获得保证
工作内存与主内存同步延迟现象导致的可见性问题
- 可通过synchronized或volatile关键字解决,他们都可以使一个线程修改后的变量立即对其它线程可见
对于指令重排导致的可见性问题和有序性问题
- 可以使用volatile关键字解决,因为volatile关键字的另一个作用就是禁止重排序优化
4.4.Volatile的应用
4.4.1.单例模式DCL代码
首先回顾一下,单线程下的单例模式代码
public class SingletonDemo {
private static SingletonDemo instance = null;
private SingletonDemo() {
System.out.println(Thread.currentThread().getName() + "\t 我是构造方法SingletonDemo");
}
public static SingletonDemo getInstance() {
if (instance == null) {
instance = new SingletonDemo();
}
return instance;
}
public static void main(String[] args) {
// 这里的 == 是比较内存地址
System.out.println(SingletonDemo.getInstance() == SingletonDemo.getInstance());
System.out.println(SingletonDemo.getInstance() == SingletonDemo.getInstance());
System.out.println(SingletonDemo.getInstance() == SingletonDemo.getInstance());
System.out.println(SingletonDemo.getInstance() == SingletonDemo.getInstance());
}
}
最后输出的结果
但是在多线程的环境下,我们的单例模式是否还是同一个对象了
for (int i = 0; i < 10; i++) {
new Thread(() -> {
SingletonDemo.getInstance();
}, String.valueOf(i)).start();
}
从下面的结果我们可以看出,我们通过SingletonDemo.getInstance() 获取到的对象,并不是同一个,而是被下面几个线程都进行了创建,那么在多线程环境下,单例模式如何保证呢?
4.4.2.多线程保证单例模式-方案1
引入synchronized关键字
public synchronized static SingletonDemo getInstance() {
if (instance == null) {
instance = new SingletonDemo();
}
return instance;
}
输出结果
我们能够发现,通过引入Synchronized关键字,能够解决高并发环境下的单例模式问题
但是synchronized属于重量级的同步机制,它只允许一个线程同时访问获取实例的方法,但是为了保证数据一致性,而减低了并发性,因此采用的比较少
4.4.3.多线程保证单例模式-方案2
通过引入DCL Double Check Lock
双端检锁机制
就是在进来和出去的时候,进行检测
public static SingletonDemo getInstance() {
if (instance == null) {
//同步代码段的时候,进行检测
synchronized (SingletonDemo.class) {
if (instance == null) {
instance = new SingletonDemo();
}
}
}
return instance;
}
最后输出的结果为
从输出结果来看,确实能够保证单例模式的正确性,但是上面的方法还是存在问题的
DCL(双端检锁)机制不一定是线程安全的,原因是有指令重排的存在,加入volatile可以禁止指令重排
原因是在某一个线程执行到第一次检测的时候,读取到 instance 不为null,instance的引用对象可能没有完成实例化。因为 instance = new SingletonDemo();可以分为以下三步进行完成:
- memory = allocate(); // 1、分配对象内存空间
- instance(memory); // 2、初始化对象
- instance = memory; // 3、设置instance指向刚刚分配的内存地址,此时instance != null
但是我们通过上面的三个步骤,能够发现,步骤2 和 步骤3之间不存在 数据依赖关系,而且无论重排前 还是重排后,程序的执行结果在单线程中并没有改变,因此这种重排优化是允许的。
- memory = allocate(); // 1、分配对象内存空间
- instance = memory; // 3、设置instance指向刚刚分配的内存地址,此时instance != null,但是对象还没有初始化完成
- instance(memory); // 2、初始化对象
这样就会造成什么问题呢?
也就是当我们执行到重排后的步骤2,试图获取instance的时候,会得到null,因为对象的初始化还没有完成,而是在重排后的步骤3才完成,因此执行单例模式的代码时候,就会重新在创建一个instance实例
指令重排只会保证串行语义的执行一致性(单线程),但并不会关系多线程间的语义一致性
所以当一个线程访问instance不为null时,由于instance实例未必已初始化完成,这就造成了线程安全的问题
所以需要引入volatile,来保证出现指令重排的问题,从而保证单例模式的线程安全性
private static volatile SingletonDemo instance = null;
4.4.4.多线程保证单例模式-volatile
最终代码:
public class SingletonDemo {
private volatile static SingletonDemo instance = null;
private SingletonDemo() {
System.out.println(Thread.currentThread().getName() + "\t 我是构造方法SingletonDemo");
}
public static SingletonDemo getInstance() {
if (instance == null) {
//a 双重检查加锁多线程情况下会出现某个线程虽然这里已经为空,但是另外一个线程已经执行到d处
synchronized (SingletonDemo.class) { //b
//c 不加volitale关键字的话有可能会出现尚未完全初始化就获取到的情况。原因是内存模型允许无序写入
if (instance == null) {
//d 此时才开始初始化
instance = new SingletonDemo();
}
}
}
return instance;
}
public static void main(String[] args) {
/*// 这里的 == 是比较内存地址
System.out.println(SingletonDemo.getInstance() == SingletonDemo.getInstance());
System.out.println(SingletonDemo.getInstance() == SingletonDemo.getInstance());
System.out.println(SingletonDemo.getInstance() == SingletonDemo.getInstance());
System.out.println(SingletonDemo.getInstance() == SingletonDemo.getInstance());*/
for (int i = 0; i < 10; i++) {
new Thread(() -> {
SingletonDemo.getInstance();
}, String.valueOf(i)).start();
}
}
}
5.谈谈CAS
5.1.概述
CAS的全称是Compare-And-Swap,它是CPU并发原语
它的功能是判断内存某个位置的值是否为预期值,如果是则更改为新的值,这个过程是原子的
CAS并发原语体现在Java语言中就是sun.misc.Unsafe类的各个方法。调用UnSafe类中的CAS方法,JVM会帮我们实现出CAS汇编指令,这是一种完全依赖于硬件的功能,通过它实现了原子操作,再次强调,由于CAS是一种系统原语,原语属于操作系统用于范畴,是由若干条指令组成,用于完成某个功能的一个过程,并且原语的执行必须是连续的,在执行过程中不允许被中断,也就是说CAS是一条CPU的原子指令,不会造成所谓的数据不一致的问题,也就是说CAS是线程安全的。
代码使用
首先调用AtomicInteger创建了一个实例, 并初始化为5
然后调用CAS方法,企图更新成2019,这里有两个参数,一个是5,表示期望值,第二个就是我们要更新的值
然后再次使用了一个方法,同样将值改成1024
// 创建一个原子类
AtomicInteger atomicInteger = new AtomicInteger(5);
atomicInteger.compareAndSet(5, 2019);
atomicInteger.compareAndSet(5, 1024);
完整代码如下:
public class CASDemo {
public static void main(String[] args) {
AtomicInteger atomicInteger = new AtomicInteger(5);
System.out.println(atomicInteger.compareAndSet(5, 2014) + "\t current data: " + atomicInteger.get());
System.out.println(atomicInteger.compareAndSet(5, 2019) + "\t current data: " + atomicInteger.get());
atomicInteger.getAndIncrement();
}
}
上面代码的执行结果为
这是因为我们执行第一个的时候,期望值和原本值是满足的,因此修改成功,但是第二次后,主内存的值已经修改成了2019,不满足期望值,因此返回了false,本次写入失败
这个就类似于SVN或者Git的版本号,如果没有人更改过,就能够正常提交,否者需要先将代码pull下来,合并代码后,然后提交
5.2.CAS底层原理
首先我们先看看 atomicInteger.getAndIncrement()方法的源码
从这里能够看到,底层又调用了一个unsafe类的getAndAddInt方法
1、unsafe类
Unsafe是CAS的核心类,由于Java方法无法直接访问底层系统,需要通过本地(Native)方法来访问,Unsafe相当于一个后门,基于该类可以直接操作特定的内存数据。Unsafe类存在sun.misc包中,其内部方法操作可以像C的指针一样直接操作内存,因为Java中的CAS操作的执行依赖于Unsafe类的方法。
注意Unsafe类的所有方法都是native修饰的,也就是说unsafe类中的方法都直接调用操作系统底层资源执行相应的任务
为什么Atomic修饰的包装类,能够保证原子性,依靠的就是底层的unsafe类
2、变量valueOffset
表示该变量值在内存中的偏移地址,因为Unsafe就是根据内存偏移地址获取数据的。
从这里我们能够看到,通过valueOffset,直接通过内存地址,获取到值,然后进行加1的操作
3、变量value用volatile修饰
保证了多线程之间的内存可见性
var5:就是我们从主内存中拷贝到工作内存中的值(每次都要从主内存拿到最新的值到自己的本地内存,然后执行compareAndSwapInt()再和主内存的值进行比较。因为线程不可以直接越过高速缓存,直接操作主内存,所以执行上述方法需要比较一次,在执行加1操作)
那么操作的时候,需要比较工作内存中的值,和主内存中的值进行比较
假设执行 compareAndSwapInt返回false,那么就一直执行 while方法,直到期望的值和真实值一样
- val1:AtomicInteger对象本身
- var2:该对象值的引用地址
- var4:需要变动的数量
- var5:用var1和var2找到的内存中的真实值
- 用该对象当前的值与var5比较
- 如果相同,更新var5 + var4 并返回true
- 如果不同,继续取值然后再比较,直到更新完成
这里没有用synchronized,而用CAS,这样提高了并发性,也能够实现一致性,是因为每个线程进来后,进入的do while循环,然后不断的获取内存中的值,判断是否为最新,然后在进行更新操作。
假设线程A和线程B同时执行getAndAddInt操作(分别跑在不同的CPU上)
- AtomicInteger里面的value原始值为3,即主内存中AtomicInteger的 value 为3,根据JMM模型,线程A和线程B各自持有一份值为3的副本,分别存储在各自的工作内存
- 线程A通过getIntVolatile(var1 , var2) 拿到value值3,这时线程A被挂起(该线程失去CPU执行权)
- 线程B也通过getIntVolatile(var1, var2)方法获取到value值也是3,此时刚好线程B没有被挂起,并执行了compareAndSwapInt方法,比较内存的值也是3,成功修改内存值为4,线程B打完收工,一切OK
- 这是线程A恢复,执行CAS方法,比较发现自己手里的数字3和主内存中的数字4不一致,说明该值已经被其它线程抢先一步修改过了,那么A线程本次修改失败,只能够重新读取后再来一遍了,也就是再执行do while
- 线程A重新获取value值,因为变量value被volatile修饰,所以其它线程对它的修改,线程A总能够看到,线程A继续执行compareAndSwapInt进行比较替换,直到成功。
Unsafe类 + CAS思想: 也就是自旋,自我旋转
5.3.CAS缺点
CAS不加锁保证一致性,但是需要多次比较
- 循环时间长,开销大(因为执行的是do while,如果比较不成功一直在循环,最差的情况,就是某个线程一直取到的值和预期值都不一样,这样就会无限循环)
- 只能保证一个共享变量的原子操作
- 当对一个共享变量执行操作时,我们可以通过循环CAS的方式来保证原子操作
- 但是对于多个共享变量操作时,循环CAS就无法保证操作的原子性,这个时候只能用锁来保证原子性
- ABA问题,狸猫换太子
总结
CAS是compareAndSwap,比较当前工作内存中的值和主物理内存中的值,如果相同则执行规定操作,否则继续比较直到主内存和工作内存的值一致为止
CAS应用:CAS有3个操作数,内存值V,旧的预期值A,要修改的更新值B。当且仅当预期值A和内存值V相同时,将内存值V修改为B,否者什么都不做
6.谈谈原子类的ABA问题
从AtomicInteger引出下面的问题
CAS -> Unsafe -> CAS底层思想 -> ABA -> 原子引用更新 -> 如何规避ABA问题
6.1.ABA问题是什么
狸猫换太子
假设现在有两个线程,分别是T1 和 T2,然后T1执行某个操作的时间为10秒,T2执行某个时间的操作是2秒,最开始AB两个线程,分别从主内存中获取A值,但是因为B的执行速度更快,他先把A的值改成B,然后再修改成A,然后执行完毕,T1线程在10秒后,执行完毕,判断内存中的值为A,并且和自己预期的值一样,它就认为没有人更改了主内存中的值,就快乐的修改成B,但是实际上 可能中间经历了 ABCDEFA 这个变换,也就是中间的值经历了狸猫换太子。
所以ABA问题就是,在进行获取主内存值的时候,该内存值在我们写入主内存的时候,已经被修改了N次,但是最终又改成原来的值了
6.1.1.CAS导致ABA问题
CAS算法实现了一个重要的前提,需要取出内存中某时刻的数据,并在当下时刻比较并替换,那么这个时间差会导致数据的变化。
比如说一个线程one从内存位置V中取出A,这时候另外一个线程two也从内存中取出A,并且线程two进行了一些操作将值变成了B,然后线程two又将V位置的数据变成A,这时候线程one进行CAS操作发现内存中仍然是A,然后线程one操作成功
尽管线程one的CAS操作成功,但是不代表这个过程就是没有问题的
CAS只管开头和结尾,也就是头和尾是一样,那就修改成功,中间的这个过程,可能会被人修改过
6.1.2.原子引用
原子引用其实和原子包装类是差不多的概念,就是将一个java类,用原子引用类进行包装起来,那么这个类就具备了原子性
class User {
String username;
Integer age;
public User(String username, Integer age) {
this.username = username;
this.age = age;
}
public String getUsername() {
return username;
}
public Integer getAge() {
return age;
}
public void setUsername(String username) {
this.username = username;
}
public void setAge(Integer age) {
this.age = age;
}
@Override
public String toString() {
return "User{" +
"username='" + username + '\'' +
", age=" + age +
'}';
}
}
public class AtomicReferenceDemo {
public static void main(String[] args) {
User z3 = new User("z3", 22);
User l4 = new User("l4", 25);
// 创建原子引用包装类
AtomicReference atomicReference = new AtomicReference<>();
// 现在主物理内存的共享变量,为z3
atomicReference.set(z3);
// 比较并交换,如果现在主物理内存的值为z3,那么交换成l4
System.out.println(atomicReference.compareAndSet(z3, l4) + "\t" + atomicReference.get().toString());
// 比较并交换,现在主物理内存的值是l4了,但是预期为z3,因此交换失败
System.out.println(atomicReference.compareAndSet(z3, l4) + "\t" + atomicReference.get().toString());
}
}
6.1.3.基于原子引用的ABA问题
我们首先创建了两个线程,然后T1线程,执行一次ABA的操作,T2线程在一秒后修改主内存的值
public class ABADemo {
//普通的原子引用包装类
public static AtomicReference atomicReference = new AtomicReference<>(100);
public static void main(String[] args) {
new Thread(() -> {
// 把100 改成 101 然后再改成100,也就是ABA
atomicReference.compareAndSet(100, 101);
atomicReference.compareAndSet(101, 100);
}, "t1").start();
new Thread(() -> {
try {
// 睡眠一秒,保证t1线程,完成了ABA操作
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
// 把100 改成 101 然后再改成100,也就是ABA
System.out.println(atomicReference.compareAndSet(100, 2021) + "\t" + atomicReference.get());
}, "t2").start();
}
}
我们发现,它能够成功的修改,这就是ABA问题
6.2.解决ABA问题
新增一种机制,也就是修改版本号,类似于时间戳的概念
T1: 100 1 2021 2
T2: 100 1 101 2 100 3
如果T1修改的时候,版本号为2,落后于现在的版本号3,所以要重新获取最新值,这里就提出了一个使用时间戳版本号,来解决ABA问题的思路
6.2.1.AtomicStampedReference
时间戳原子引用,来这里应用于版本号的更新,也就是每次更新的时候,需要比较期望值和当前值,以及期望版本号和当前版本号
public class ABADemo {
//普通的原子引用包装类
public static AtomicReference atomicReference = new AtomicReference<>(100);
// 传递两个值,一个是初始值,一个是初始版本号
public static AtomicStampedReference atomicStampedReference = new AtomicStampedReference<>(100, 1);
public static void main(String[] args) {
System.out.println("============以下是ABA问题的产生==========");
new Thread(() -> {
// 把100 改成 101 然后再改成100,也就是ABA
atomicReference.compareAndSet(100, 101);
atomicReference.compareAndSet(101, 100);
}, "t1").start();
new Thread(() -> {
try {
// 睡眠一秒,保证t1线程,完成了ABA操作
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
// 把100 改成 101 然后再改成100,也就是ABA
System.out.println(atomicReference.compareAndSet(100, 2021) + "\t" + atomicReference.get());
}, "t2").start();
System.out.println("============以下是ABA问题的解决==========");
new Thread(() -> {
// 获取版本号
System.out.println(Thread.currentThread().getName() + "\t第一次版本号:" + atomicStampedReference.getStamp());
try {
// 暂停t3一秒钟
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
// 传入4个值,期望值,更新值,期望版本号,更新版本号
atomicStampedReference.compareAndSet(100, 101, atomicStampedReference.getStamp(), atomicStampedReference.getStamp() + 1);
System.out.println(Thread.currentThread().getName() + "\t第二次版本号:" + atomicStampedReference.getStamp());
atomicStampedReference.compareAndSet(101, 100, atomicStampedReference.getStamp(), atomicStampedReference.getStamp() + 1);
System.out.println(Thread.currentThread().getName() + "\t第三次版本号:" + atomicStampedReference.getStamp());
}, "t3").start();
new Thread(() -> {
// 获取版本号
int stamp = atomicStampedReference.getStamp();
System.out.println(Thread.currentThread().getName() + "\t第一次版本号:" + stamp);
try {
// 暂停t4 3秒钟,保证t3线程也进行一次ABA问题
TimeUnit.SECONDS.sleep(3);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName() + "\t修改成功否:" +
atomicStampedReference.compareAndSet(100, 2021, stamp, stamp + 1) +
"\t当前实际版本号:" + atomicStampedReference.getStamp() +
"\t当前实际最新值:" + atomicStampedReference.getReference());
}, "t4").start();
}
}
运行结果为:
我们能够发现,线程t3,在进行ABA操作后,版本号变更成了3,而线程t4在进行操作的时候,就出现操作失败了,因为版本号和当初拿到的不一样
7.集合的线程安全
7.1.ArrayList集合线程不安全问题
例子:创建多个线程,向List集合添加内容,并获取内容
演示代码:
public class ThreadDemo4 {
public static void main(String[] args) {
//创建ArrayList集合
List list = new ArrayList<>();
for (int i = 0; i < 10; i++) {
new Thread(() -> {
//向集合添加内容
list.add(UUID.randomUUID().toString().substring(0, 8));
//从集合获取内容
System.out.println(list);
}, String.valueOf(i)).start();
}
}
}
结果:抛出并发修改异常java.util.ConcurrentModificationException,原因是从集合中获取内容时,有线程向集合中添加内容或修改内容
7.1.1.解决方案-Vector--JDK1.0比较古老,开发中比较少用
代码实现:
public class ThreadDemo4 {
public static void main(String[] args) {
//创建ArrayList集合
// List list = new ArrayList<>();
//Vector解决
List list = new Vector<>();
for (int i = 0; i < 10; i++) {
new Thread(() -> {
//向集合添加内容
list.add(UUID.randomUUID().toString().substring(0, 8));
//从集合获取内容
System.out.println(list);
}, String.valueOf(i)).start();
}
}
}
结果:
7.1.2.解决方案-Collections--开发中比较少用
java.util.Collections类静态方法synchronizedList(List
代码实现:
public class ThreadDemo4 {
public static void main(String[] args) {
//创建ArrayList集合
// List list = new ArrayList<>();
//Vector解决
// List list = new Vector<>();
//Collections解决
List list = Collections.synchronizedList(new ArrayList<>());
for (int i = 0; i < 10; i++) {
new Thread(() -> {
//向集合添加内容
list.add(UUID.randomUUID().toString().substring(0, 8));
//从集合获取内容
System.out.println(list);
}, String.valueOf(i)).start();
}
}
}
7.1.3.解决方案-JUC类CopyOnWriteArrayList--推荐
代码实现:
public class ThreadDemo4 {
public static void main(String[] args) {
//创建ArrayList集合
// List list = new ArrayList<>();
//Vector解决
// List list = new Vector<>();
//Collections解决
// List list = Collections.synchronizedList(new ArrayList<>());
//CopyOnWriteArrayList解决
List list = new CopyOnWriteArrayList<>();
for (int i = 0; i < 10; i++) {
new Thread(() -> {
//向集合添加内容
list.add(UUID.randomUUID().toString().substring(0, 8));
//从集合获取内容
System.out.println(list);
}, String.valueOf(i)).start();
}
}
}
CopyOnWriteArrayList使用写时复制技术
,实现集合并发读-独立写功能
原理:向集合中写数据时支持独立写:复制一份集合写入新的内容,写完后合并或覆盖原集合
源码分析:
/**
* Appends the specified element to the end of this list.
*
* @param e element to be appended to this list
* @return {@code true} (as specified by {@link Collection#add})
*/
public boolean add(E e) {
final ReentrantLock lock = this.lock;
//上锁
lock.lock();
try {
Object[] elements = getArray();
int len = elements.length;
Object[] newElements = Arrays.copyOf(elements, len + 1);//复制一份
newElements[len] = e;
setArray(newElements);//覆盖
return true;
} finally {
//释放锁
lock.unlock();
}
}
7.2.HashSet集合线程不安全
代码演示:
public class ThreadDemo4 {
public static void main(String[] args) {
//创建HashSet集合
Set set = new HashSet<>();
for (int i = 0; i < 10; i++) {
new Thread(() -> {
//向集合添加内容
set.add(UUID.randomUUID().toString().substring(0, 8));
//从集合获取内容
System.out.println(set);
}, String.valueOf(i)).start();
}
}
}
结果:抛出并发修改异常java.util.ConcurrentModificationException
7.2.1.解决方案-JUC类CopyOnWriteArraySet
代码演示:
public class ThreadDemo4 {
public static void main(String[] args) {
//创建HashSet集合
// Set set = new HashSet<>();
//CopyOnWriteArraySet解决
Set set = new CopyOnWriteArraySet<>();
for (int i = 0; i < 10; i++) {
new Thread(() -> {
//向集合添加内容
set.add(UUID.randomUUID().toString().substring(0, 8));
//从集合获取内容
System.out.println(set);
}, String.valueOf(i)).start();
}
}
}
7.3.HashMap集合线程不安全
代码演示:
public class ThreadDemo4 {
public static void main(String[] args) {
//创建HashMap集合
Map map = new HashMap<>();
for (int i = 0; i < 10; i++) {
String key = String.valueOf(i);
new Thread(() -> {
//向集合添加内容
map.put(key, UUID.randomUUID().toString().substring(0, 8));
//从集合获取内容
System.out.println(map);
}, String.valueOf(i)).start();
}
}
}
结果:抛出并发修改异常java.util.ConcurrentModificationException
7.3.1.解决方案-JUC类ConcurrentHashMap
代码演示:
public class ThreadDemo4 {
public static void main(String[] args) {
//创建HashMap集合
// Map map = new HashMap<>();
//ConcurrentHashMap解决
Map map = new ConcurrentHashMap<>();
for (int i = 0; i < 10; i++) {
String key = String.valueOf(i);
new Thread(() -> {
//向集合添加内容
map.put(key, UUID.randomUUID().toString().substring(0, 8));
//从集合获取内容
System.out.println(map);
}, String.valueOf(i)).start();
}
}
}
8.多线程锁
synchronized实现同步的基础:Java中的每一个对象都可以作为锁,具体表现为以下3中形式。
对于普通同步方法,锁匙当前实例对象。
对于静态同步方法,锁是当前类的Class对象。
对于同步方法块,锁是synchronized括号里配置的对象。
8.1.公平锁和非公平锁
非公平锁特点:线程饿死,效率高,不需要排队,谁先抢到就是谁的
公平锁特点:阳光普照,进行操作之前,先检查是否有人排队,如果有人就排队,没有才直接操作,效率相对低
非公平锁源码:
static final class NonfairSync extends Sync {
private static final long serialVersionUID = 7316153563782823691L;
/**
* Performs lock. Try immediate barge, backing up to normal
* acquire on failure.
*/
final void lock() {
if (compareAndSetState(0, 1))
setExclusiveOwnerThread(Thread.currentThread());
else
acquire(1);
}
protected final boolean tryAcquire(int acquires) {
return nonfairTryAcquire(acquires);
}
}
公平锁源码:
static final class FairSync extends Sync {
private static final long serialVersionUID = -3000897897090466540L;
final void lock() {
acquire(1);
}
/**
* Fair version of tryAcquire. Don't grant access unless
* recursive call or no waiters or is first.
*/
protected final boolean tryAcquire(int acquires) {
final Thread current = Thread.currentThread();
int c = getState();
if (c == 0) {
if (!hasQueuedPredecessors() &&
compareAndSetState(0, acquires)) {//有人排队吗
setExclusiveOwnerThread(current);
return true;
}
}
else if (current == getExclusiveOwnerThread()) {
int nextc = c + acquires;
if (nextc < 0)
throw new Error("Maximum lock count exceeded");
setState(nextc);
return true;
}
return false;
}
}
并发包中ReentrantLock的创建可以指定构造函数的boolean类型来得到公平锁或者非公平锁,默认是非公平锁,因为非公平锁的优点在于吞吐量比公平锁大,对于synchronized
而言,也是一种非公平锁
/**
* 创建一个可重入锁,true 表示公平锁,false 表示非公平锁。默认非公平锁
*/
Lock lock = new ReentrantLock(true);
8.2.可重入锁
synchronized和Lock都是可重入锁
区别:
1.sychronized是隐式的可重入锁,而Lock是显式的可重入锁。
2.sychronized加锁和释放锁是自动完成的,而Lock加锁和释放锁需要手动完成。
可重入锁又叫递归锁,进入第一道大门,里面的门可以实现无障碍进入
8.3.自旋锁
自旋锁:spinlock,是指尝试获取锁的线程不会立即阻塞,而是采用循环的方式去尝试获取锁,这样的好处是减少线程上下文切换的消耗,缺点是循环会消耗CPU
原来提到的比较并交换,底层使用的就是自旋,自旋就是多次尝试,多次访问,不会阻塞的状态就是自旋。
优缺点
优点:循环比较获取直到成功为止,没有类似于wait的阻塞
缺点:当不断自旋的线程越来越多的时候,会因为执行while循环不断的消耗CPU资源
手写自旋锁
通过CAS操作完成自旋锁,A线程先进来调用myLock方法自己持有锁5秒,B随后进来发现当前有线程持有锁,不是null,所以只能通过自旋等待,直到A释放锁后B随后抢到
/**
* 手写一个自旋锁
* 循环比较获取直到成功为止,没有类似于wait的阻塞
*
* 通过CAS操作完成自旋锁,A线程先进来调用myLock方法自己持有锁5秒,B随后进来发现当前有线程持有锁,不是null,所以只能通过自旋等待,直到A释放锁后B随后抢到
*/
public class SpinLockDemo {
// 现在的泛型装的是Thread,原子引用线程
AtomicReference atomicReference = new AtomicReference<>();
public void myLock() {
// 获取当前进来的线程
Thread thread = Thread.currentThread();
System.out.println(Thread.currentThread().getName() + "\t come in");
// 开始自旋,期望值是null,更新值是当前线程,如果是null,则更新为当前线程,否者自旋
while (!atomicReference.compareAndSet(null, thread)) {
}
}
public void myUnLock() {
// 获取当前进来的线程
Thread thread = Thread.currentThread();
// 自己用完了后,把atomicReference变成null
atomicReference.compareAndSet(thread, null);
System.out.println(Thread.currentThread().getName() + "\t invoked myUnLock");
}
public static void main(String[] args) throws InterruptedException {
SpinLockDemo spinLockDemo = new SpinLockDemo();
// 启动t1线程,开始操作
new Thread(() -> {
// 开始占有锁
spinLockDemo.myLock();
try {
TimeUnit.SECONDS.sleep(5);
} catch (InterruptedException e) {
e.printStackTrace();
}
// 开始释放锁
spinLockDemo.myUnLock();
}, "t1").start();
// 让main线程暂停1秒,使得t1线程,先执行
TimeUnit.SECONDS.sleep(1);
new Thread(() -> {
// 开始占有锁
spinLockDemo.myLock();
// 开始释放锁
spinLockDemo.myUnLock();
}, "t2").start();
}
}
最后输出结果
首先输出的是 t1 come in
然后1秒后,t2线程启动,发现锁被t1占有,所有不断的执行 compareAndSet方法,来进行比较,直到t1释放锁后,也就是5秒后,t2成功获取到锁,然后释放
8.4.读写锁
独占锁(写锁) / 共享锁(读锁) / 互斥锁
独占锁:指该锁一次只能被一个线程所持有。对ReentrantLock和Synchronized而言都是独占锁
共享锁:指该锁可以被多个线程锁持有
对ReentrantReadWriteLock其读锁是共享,其写锁是独占
写的时候只能一个人写,但是读的时候,可以多个人同时读
为什么会有写锁和读锁?
原来我们使用ReentrantLock创建锁的时候,是独占锁,也就是说一次只能一个线程访问,但是有一个读写分离场景,读的时候想同时进行,因此原来独占锁的并发性就没这么好了,因为读锁并不会造成数据不一致的问题,因此可以多个人共享读
读-读:能共存
读-写:不能共存
写-写:不能共存
8.5.死锁
1.什么是死锁
两个或者两个以上进程在执行过程中,因为争夺资源而造成一种互相等待的现象
,如果没有外力干涉,它们无法再执行下去。
2.产生死锁的原因
- 第一 系统资源不足
- 第二 进程进行推进顺序不合适
- 第三 资源分配不当
3.代码演示
//演示死锁
public class DeadLock {
//创建两个对象
static Object a = new Object();
static Object b = new Object();
public static void main(String[] args) {
new Thread(() -> {
synchronized (a) {
System.out.println(Thread.currentThread().getName() + " 持有锁a,试图获取锁b");
try {
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized (b) {
System.out.println(Thread.currentThread().getName() + " 获取锁b");
}
}
}, "A").start();
new Thread(() -> {
synchronized (b) {
System.out.println(Thread.currentThread().getName() + " 持有锁b,试图获取锁a");
try {
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized (a) {
System.out.println(Thread.currentThread().getName() + " 获取锁a");
}
}
}, "B").start();
}
}
死锁结果:
4.验证是否是死锁
(1).jps
类似Linux中ps -ef,能查看到当前正在运行的进程
使用要求:需要配置jdk/bin/jps.exe到环境变量,或者直接在该路径打开运行
(2).jstack
JVM自带的堆栈跟踪工具,使用jstack PID查看堆栈信息
死锁的验证:
9.Callable接口
使用Runable接口创建线程,无法使线程返回结果,而Callable接口可以实现线程返回结果。
创建线程的多种方式:
- 第一种 继承Thread类
- 第二种 实现Runable接口
- 第三种 实现Callable接口
- 第四种 使用线程池
Runable接口与Callable接口的区别:
- (1)是否有返回值,Callable接口有返回值,而Runable接口没有
- (2)是否抛出异常,Callable接口可以抛出异常,而Ruanble接口没有
- (3)实现方法名称不同,一个是run方法,一个是call方法
使用Callable接口:
分析:在Thread构造方法中,有Runable参数,而没有Callable参数来创建线程
解决方案:找到一个类,既和Runable有关系,又和Callable有关系
- Runable接口有实现类FutureTask
- FutureTask(Callable
callable):创建一个 FutureTask,一旦运行就执行给定的 Callable。
- FutureTask(Callable
- FutureTask构造可以传递Callable
创建线程:
//实现Runable接口
class MyThread implements Runnable {
@Override
public void run() {
}
}
public class Demo1 {
public static void main(String[] args) throws ExecutionException, InterruptedException {
//Runable接口创建线程
new Thread(new MyThread(), "AA").start();
/**
* FutureTask原理 未来任务
* 1、老师上课,口渴了,去买水不合适,讲课线程继续。
* 单开线程找班长帮我买水,把水买回来,需要时候直接get
* 2、4个同学:1同学 1+2+...+5, 2同学 10+11+12+...+50, 3同学 60+61+62, 4同学 100+200
* 第二个同学计算量比较大,FutureTask单开线程给2同学计算,先汇总1、3、4,最后等2同学计算完成,统一汇总
* 3、考试,先做会做的题目,最后看不会做的题目
*/
//FutureTask使用Callable创建线程
FutureTask futureTask = new FutureTask<>(() -> {
System.out.println(Thread.currentThread().getName() + " come in callable");
return 1024;
});
//创建一个线程
new Thread(futureTask, "Lucy").start();
while (!futureTask.isDone()) {
System.out.println("wait...");
}
//调用FutureTask的get方法
System.out.println(futureTask.get());
System.out.println(futureTask.get());
System.out.println(Thread.currentThread().getName() + "come over");
}
}
注意:
多个线程执行 一个FutureTask的时候,只会计算一次
FutureTask futureTask = new FutureTask<>(new MyThread2());
// 开启两个线程计算futureTask
new Thread(futureTask, "AAA").start();
new Thread(futureTask, "BBB").start();
如果我们要两个线程同时计算任务的话,那么需要这样写,需要定义两个futureTask
FutureTask futureTask = new FutureTask<>(new MyThread2());
FutureTask futureTask2 = new FutureTask<>(new MyThread2());
// 开启两个线程计算futureTask
new Thread(futureTask, "AAA").start();
new Thread(futureTask2, "BBB").start();
10.JUC强大的辅助类
10.1.减少计数CountDownLatch
CountDownLatch类可以设置一个计数器,然后通过countDown方法来进行减1的操作,使用await方法等待计数器不大于0,然后继续执行await方法之后的语句。
- CountDownLatch主要有两个方法,当一个或多个线程调用await方法时,这些线程会阻塞
- 其它线程调用countDown方法会将计数器减1(调用countDown方法的线程不会阻塞)
- 当计数器的值变为0时,因await方法阻塞的线程会被唤醒,继续执行
场景:6个同学陆续离开教室后值班同学才可以锁门
//演示CountDownLatch
public class CountDownLatchDemo {
//6个同学陆续离开教室后,班长锁门
public static void main(String[] args) throws InterruptedException {
//创建CountDownLatch对象,设置初始值
CountDownLatch countDownLatch = new CountDownLatch(6);
//6个同学陆续离开教室
for (int i = 1; i <= 6; i++) {
new Thread(() -> {
System.out.println(Thread.currentThread().getName() + " 号同学离开了教室");
//计数-1
countDownLatch.countDown();
}, String.valueOf(i)).start();
}
//等待
countDownLatch.await();
System.out.println(Thread.currentThread().getName() + " 班长锁门走人了");
}
}
10.2.循环栅栏CyclicBarrier
和CountDownLatch相反,需要集齐七颗龙珠,召唤神龙。也就是做加法,开始是0,加到某个值的时候就执行
CyclicBarrier大概就是循环阻塞的意思,在使用中CyclicBarrier的构造方法第一个参数是目标障碍数,每次执行CyclicBarrier一次障碍数会加一,如果达到了目标障碍数,才会执行cyclicBarrier.await()之后的语句。可以将CyclicBarrier理解为加1操作
场景:集齐7颗龙珠就可以召唤神龙
//集齐7颗龙珠可以召唤神龙
public class CyclicBarrierDemo {
//创建固定值
public static final int NUMBER = 7;
public static void main(String[] args) {
//创建CyclicBarrier
CyclicBarrier cyclicBarrier = new CyclicBarrier(NUMBER, () -> {
System.out.println("已集齐7颗龙珠,召唤神龙");
});
//集齐7颗龙珠过程
for (int i = 1; i <= NUMBER; i++) {
new Thread(() -> {
try {
System.out.println(Thread.currentThread().getName() + " 号龙珠被收集了");
//等待
cyclicBarrier.await();
} catch (Exception e) {
e.printStackTrace();
}
}, String.valueOf(i)).start();
}
}
}
10.3.信号灯Samaphore
信号灯主要用于两个目的
- 一个是用于共享资源的互斥使用
- 另一个用于并发线程数的控制
一个计数信号量。从概念上讲,信号量维护了一个许可集。如有必要,在许可可用前会阻塞每一个 acquire(),然后再获取该许可。每个 release() 添加一个许可,从而可能释放一个正在阻塞的获取者。但是,不使用实际的许可对象,Semaphore 只对可用许可的号码进行计数,并采取相应的行动。
场景:抢占车位,6辆汽车,停3个车位
//6辆汽车,停3个车位
public class SemaphoreDemo {
public static void main(String[] args) {
//创建Semaphore,设置许可数量
Semaphore semaphore = new Semaphore(3);
//模拟6辆汽车
for (int i = 1; i <= 6; i++) {
new Thread(() -> {
try {
//抢占
semaphore.acquire();
System.out.println(Thread.currentThread().getName() + " 号车抢到了车位");
//设置随机停车时间
TimeUnit.SECONDS.sleep(new Random().nextInt(5));
System.out.println(Thread.currentThread().getName() + " 号车离开了车位");
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
//释放
semaphore.release();
}
}, String.valueOf(i)).start();
}
}
}
11.ReentrantReadWriteLock读写锁
读锁:共享锁,会发生死锁
- 场景:1线程修改时,需要等待2线程读完之后;2线程修改时,需要等待1线程读完之后,互相等待对方读锁释放并占有锁,造成死锁
写锁:独占锁,会发生死锁
- 场景:1线程对第1行记录持有写操作时,并试图获取第2行记录的写操作;而2线程对第2行记录持有写操作时,并试图获取第1行记录的写操作,互相等待对方写锁释放并占有锁,造成死锁
11.1.读写锁案例
读写操作问题演示:
//资源类
class MyCache {
//创建map集合
private volatile Map map = new HashMap<>();
//放数据
public void put(String key, Object value) {
System.out.println(Thread.currentThread().getName() + " 正在写操作" + key);
//暂停一会
try {
TimeUnit.MICROSECONDS.sleep(300);
} catch (InterruptedException e) {
e.printStackTrace();
}
//放数据
map.put(key, value);
System.out.println(Thread.currentThread().getName() + " 写完了" + key);
}
//取数据
public Object get(String key) {
Object result = null;
System.out.println(Thread.currentThread().getName() + " 正在读操作" + key);
//暂停一会
try {
TimeUnit.MICROSECONDS.sleep(300);
} catch (InterruptedException e) {
e.printStackTrace();
}
//取数据
result = map.get(key);
System.out.println(Thread.currentThread().getName() + " 读完了" + key);
return result;
}
}
public class ReadWriteLockDemo {
public static void main(String[] args) {
MyCache myCache = new MyCache();
//创建线程放数据
for (int i = 1; i <= 5; i++) {
int num = i;
new Thread(() -> {
myCache.put(num + "", num + "");
}, String.valueOf(i)).start();
}
//创建线程取数据
for (int i = 1; i <= 5; i++) {
int num = i;
new Thread(() -> {
myCache.get(num + "");
}, String.valueOf(i)).start();
}
}
}
结果:数据读写未同步,读取数据在写入之前先发生了
解决方案:ReentrantReadWriteLock加读写锁
- 在资源类中创建读写锁对象:private ReadWriteLock rwLock = new ReentrantReadWriteLock();
- 操作方法中添加读/写锁:rwLock.writeLock().lock();或rwLock.readLock().lock();
- 操作方法中释放读/写锁:rwLock.writeLock().unlock();或rwLock.readLock().unlock();
效果:
11.2.读写锁深入
读写锁:一个资源可以被多个读线程访问,或者可以被一个写线程访问,但是不能同时存在读写线程,读写互斥,读读共享的。
读写锁的演变:
读写锁的降级:将写锁降级为读锁,读锁不能升级为写锁
jdk8说明:获取写锁=>获取读锁=>释放写锁=>释放读锁
写锁降级为读锁演示:
public class Demo1 {
public static void main(String[] args) {
//可重入读写锁对象
ReentrantReadWriteLock rwLock = new ReentrantReadWriteLock();
ReentrantReadWriteLock.ReadLock readLock = rwLock.readLock();//读锁
ReentrantReadWriteLock.WriteLock writeLock = rwLock.writeLock();//写锁
//锁降级
//1.获取写锁
writeLock.lock();
System.out.println("1-获取到写锁");
//2.获取读锁
readLock.lock();
System.out.println("2-获取到读锁");
//3.释放写锁
writeLock.unlock();
//4.释放读锁
readLock.unlock();
}
}
结果:
反之,读锁不能升级为写锁:
12.BlockingQueue阻塞队列
阻塞队列,顾名思义,首先它是一个队列,通过一个共享的队列,可以使得数据由队列的一端输入,从另一端输出;
当队列是空的,从队列中获取元素的操作将会被阻塞;
当队列是满的,从队列中添加元素的操作将会被阻塞;
试图从空的队列中获取元素的线程将会被阻塞,直到其它线程往空的队列插入新的元素;
试图向已满的队列中添加新元素的线程将会被阻塞,直到其它线程从队列中移除一个或多个元素或者完全清空,使队列变得空闲起来并后续新增
在多线程领域:所谓阻塞,在某些情况下会挂起线程(即阻塞),一旦条件满足,被挂起的线程又会自动被唤起。
为什么需要BlockingQueue?
好处是我们不需要关心什么时候需要阻塞线程,什么时候需要唤醒线程,因为这一切BlockingQueue都给你一手包办了。
在java.util.concurrent包发布以前,在多线程环境下,我们每个程序员都必须去自己控制这些细节,尤其还要兼顾效率和线程安全,而这会给我们的程序带来不小的复杂度。
12.1.常见的BlockingQueue
BlockingQueue阻塞队列是属于一个接口,底下有七个实现类
-
ArrayBlockingQueue(常用)
基于数组的阻塞队列实现,维护了一个定长的数组,以便缓存队列中的数据对象。
总结:由数组结构组成的有界阻塞队列。 -
LinkedBlockingQueu(常用)
基于链表的阻塞队列。
总结:由链表结构组成的有界(但大小默认值为Integer.MAX_VALUE)的阻塞队列。 -
DelayQueue
使用优先级队列实现的延迟无界阻塞队列。 -
PriorityBlockingQueue
支持优先级排序的无界阻塞队列 -
SynchronousQueue(常用)
不存储元素的阻塞队列,也即单个元素的队列。 -
LinkedTransferQueue
由链表组成的无界阻塞队列。 -
LinkedBlockingDeque
由链表组成的双向阻塞队列。
这里需要掌握的是:ArrayBlockQueue、LinkedBlockingQueue、SynchronousQueue
12.2.BlockingQueue核心方法
方法类型 | 抛出异常 | 特殊值 | 阻塞 | 超时 |
---|---|---|---|---|
插入 | add(e) | offer(e) | put(e) | offer(e,time,unit) |
移除 | remove() | poll() | take() | poll(time,unit) |
检查 | element() | peek() | 不可用 | 不可用 |
核心方法演示:
public class BlockingQueueDemo {
public static void main(String[] args) throws InterruptedException {
//创建阻塞队列
BlockingQueue blockingQueue = new ArrayBlockingQueue<>(3);
//第一组,执行add方法,向已经满的ArrayBlockingQueue中添加元素时候,会抛出异常;同时如果我们多取出元素的时候,也会抛出异常
/*System.out.println(blockingQueue.add("a")); //true
System.out.println(blockingQueue.add("b")); //true
System.out.println(blockingQueue.add("c")); //true
System.out.println(blockingQueue.element()); //a
//System.out.println(blockingQueue.add("w")); //Exception in thread "main" java.lang.IllegalStateException: Queue full
System.out.println(blockingQueue.remove()); //a
System.out.println(blockingQueue.remove()); //b
System.out.println(blockingQueue.remove()); //c
System.out.println(blockingQueue.remove()); //Exception in thread "main" java.util.NoSuchElementException*/
//第二组,使用 offer的方法,添加元素时候,如果阻塞队列满了后,会返回false,否者返回true;同时在取的时候,如果队列已空,那么会返回null
/*System.out.println(blockingQueue.offer("a")); //true
System.out.println(blockingQueue.offer("b")); //true
System.out.println(blockingQueue.offer("c")); //true
System.out.println(blockingQueue.offer("w")); //false
System.out.println(blockingQueue.poll()); //a
System.out.println(blockingQueue.poll()); //b
System.out.println(blockingQueue.poll()); //c
System.out.println(blockingQueue.poll()); //null*/
//第三组,使用 put的方法,添加元素时候,如果阻塞队列满了后,添加消息的线程会一直阻塞,直到队列元素减少或被清空,才会唤醒;同时使用take取消息的时候,如果内容不存在的时候,也会被阻塞
//一般在消息中间件,比如RabbitMQ中会使用到,因为需要保证消息百分百不丢失,因此只有让它阻塞
/*blockingQueue.put("a");
blockingQueue.put("b");
blockingQueue.put("c");
//blockingQueue.put("w"); //队列满了,线程阻塞
System.out.println(blockingQueue.take()); //a
System.out.println(blockingQueue.take()); //b
System.out.println(blockingQueue.take()); //c
//System.out.println(blockingQueue.take()); //队列空了,线程阻塞*/
//第四组,使用offer插入的时候,需要指定时间,如果2秒还没有插入,那么就放弃插入;同时取的时候也进行判断,如果2秒内取不出来,那么就返回null
System.out.println(blockingQueue.offer("a")); //true
System.out.println(blockingQueue.offer("b")); //true
System.out.println(blockingQueue.offer("c")); //true
System.out.println(blockingQueue.offer("w", 2L, TimeUnit.SECONDS)); //false,超时后阻塞结束
System.out.println(blockingQueue.poll()); //a
System.out.println(blockingQueue.poll()); //b
System.out.println(blockingQueue.poll()); //c
System.out.println(blockingQueue.poll(2L, TimeUnit.SECONDS)); //null,超时后阻塞结束
}
}
SynchronousQueue
SynchronousQueue没有容量,与其他BlockingQueue不同,SynchronousQueue是一个不存储的BlockingQueue,每一个put操作必须等待一个take操作,否则不能继续添加元素
例子:创建了两个线程,一个线程用于生产,一个线程用于消费
public class SynchronousQueueDemo {
public static void main(String[] args) {
BlockingQueue blockingQueue = new SynchronousQueue<>();
new Thread(() -> {
//生产的线程分别put了 A、B、C这三个字段
try {
System.out.println(Thread.currentThread().getName() + "\t put A");
blockingQueue.put("A");
System.out.println(Thread.currentThread().getName() + "\t put B");
blockingQueue.put("B");
System.out.println(Thread.currentThread().getName() + "\t put C");
blockingQueue.put("C");
} catch (InterruptedException e) {
e.printStackTrace();
}
}, "t1").start();
new Thread(() -> {
//消费线程使用take,取出消费阻塞队列中的内容,并且每次消费前,都等待5秒
try {
TimeUnit.SECONDS.sleep(5);
blockingQueue.take();
System.out.println(Thread.currentThread().getName() + "\t take A");
TimeUnit.SECONDS.sleep(5);
blockingQueue.take();
System.out.println(Thread.currentThread().getName() + "\t take B");
TimeUnit.SECONDS.sleep(5);
blockingQueue.take();
System.out.println(Thread.currentThread().getName() + "\t take C");
} catch (InterruptedException e) {
e.printStackTrace();
}
}, "t2").start();
}
}
最后结果输出为:
从最后的运行结果可以看出,每次t1线程向阻塞队列添加元素后,t1输入线程就会等待 t2消费线程,t2消费后,t2处于挂起状态,等待t1再存入,从而周而复始,形成 一存一取的状态
12.3.阻塞队列的用处
12.3.1.生产者消费者模式-传统版
//第一步 创建资源类 定义属性和操作方法
class Share {
//初始值
private int number = 0;
//创建Lock
private Lock lock = new ReentrantLock();
private Condition condition = lock.newCondition();
//+1
public void incr() throws InterruptedException {
try {
//上锁
lock.lock();
//第二步 判断 干活 通知
while (number != 0) {
condition.await(); //在哪睡在哪醒,使用循环判断防止虚假唤醒
}
number++;
System.out.println(Thread.currentThread().getName() + "::" + number);
condition.signalAll();//通知其他线程
} finally {
//释放锁
lock.unlock();
}
}
//-1
public void decr() throws InterruptedException {
try {
lock.lock();
//第二步 判断 干活 通知
while (number != 1) {
condition.await(); //在哪睡在哪醒,使用循环判断防止虚假唤醒
}
number--;
System.out.println(Thread.currentThread().getName() + "::" + number);
condition.signalAll();//通知其他线程
} finally {
//释放锁
lock.unlock();
}
}
}
public class ProdConsumerTraditionDemo {
public static void main(String[] args) {
//第三步 创建多个线程 调用资源类的操作方法
Share share = new Share();
// t1线程,生产
new Thread(() -> {
for (int i = 0; i < 10; i++) {
try {
share.incr(); //+1
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}, "t1").start();
// t2线程,消费
new Thread(() -> {
for (int i = 0; i < 10; i++) {
try {
share.decr(); //-1
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}, "t2").start();
}
}
最后运行成功后,一个进行生产,一个进行消费
12.3.2.生产者消费者模式-阻塞队列版
在concurrent包发布以前,在多线程环境下,我们每个程序员都必须自己去控制这些细节,尤其还要兼顾效率和线程安全,则这会给我们的程序带来不小的时间复杂度
现在我们使用新版的阻塞队列版生产者和消费者,使用:volatile、CAS、atomicInteger、BlockQueue、线程交互、原子引用
class MyResource {
// 默认开启,进行生产消费
// 这里用到了volatile是为了保持数据的可见性,也就是当flag修改时,要马上通知其它线程进行修改
private volatile boolean flag = true;
// 使用原子包装类,而不用number++
private AtomicInteger atomicInteger = new AtomicInteger();
// 这里不能为了满足条件,而实例化一个具体的SynchronousBlockingQueue
BlockingQueue blockingQueue;
// 而应该采用依赖注入里面的,构造注入方法传入
public MyResource(BlockingQueue blockingQueue) {
this.blockingQueue = blockingQueue;
System.out.println(blockingQueue.getClass().getName());
}
public void myProd() throws InterruptedException {
String data;
boolean retValue;
// 多线程环境的判断,一定要使用while进行,防止出现虚假唤醒
// 当flag为true的时候,开始生产
while (flag) {
data = atomicInteger.incrementAndGet() + "";
retValue = blockingQueue.offer(data, 2L, TimeUnit.SECONDS);
if (retValue) {
System.out.println(Thread.currentThread().getName() + "\t 插入队列:" + data + "成功");
} else {
System.out.println(Thread.currentThread().getName() + "\t 插入队列:" + data + "失败");
}
TimeUnit.SECONDS.sleep(1);
}
System.out.println(Thread.currentThread().getName() + "\t 停止生产,表示flag=false,生产结束");
}
public void myConsumer() throws InterruptedException {
String retValue;
// 多线程环境的判断,一定要使用while进行,防止出现虚假唤醒
// 当flag为true的时候,开始消费
while (flag) {
retValue = blockingQueue.poll(2L, TimeUnit.SECONDS);
if (retValue != null && retValue != "") {
System.out.println(Thread.currentThread().getName() + "\t 消费队列:" + retValue + "成功");
} else {
flag = false;
System.out.println(Thread.currentThread().getName() + "\t 消费失败,队列中已为空,退出");
return;
}
TimeUnit.SECONDS.sleep(1);
}
}
public void stop() {
this.flag = false;
}
}
public class ProdConsumerBlockingQueueDemo {
public static void main(String[] args) throws InterruptedException {
// 传入具体的实现类, ArrayBlockingQueue
MyResource myResource = new MyResource(new ArrayBlockingQueue<>(10));
new Thread(() -> {
System.out.println(Thread.currentThread().getName() + "\t 生产线程启动");
System.out.println();
System.out.println();
try {
myResource.myProd();
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println();
System.out.println();
}, "prod").start();
new Thread(() -> {
System.out.println(Thread.currentThread().getName() + "\t 消费线程启动");
try {
myResource.myConsumer();
} catch (InterruptedException e) {
e.printStackTrace();
}
}, "consumer").start();
// 5秒后,停止生产和消费
TimeUnit.SECONDS.sleep(5);
System.out.println();
System.out.println();
System.out.println("5秒钟后,生产和消费线程停止,线程结束");
myResource.stop();
}
}
最后运行结果
13.Synchronized和Lock的区别
早期的时候我们对线程的主要操作为:
- synchronized wait notify
然后后面出现了替代方案
- lock await signal
1.synchronized 和 lock 有什么区别?用新的lock有什么好处?举例说明
1)synchronized属于JVM层面,属于java的关键字
- monitorenter(底层是通过monitor对象来完成,其实wait/notify等方法也依赖于monitor对象 只能在同步块或者方法中才能调用 wait/ notify等方法)
- Lock是具体类(java.util.concurrent.locks.Lock)是api层面的锁
2)使用方法:
- synchronized:不需要用户去手动释放锁,当synchronized代码执行后,系统会自动让线程释放对锁的占用
- ReentrantLock:则需要用户去手动释放锁,若没有主动释放锁,就有可能出现死锁的现象,需要lock() 和 unlock() 配置try catch语句来完成
3)等待是否中断
- synchronized:不可中断,除非抛出异常或者正常运行完成
- ReentrantLock:可中断,可以设置超时方法
- 设置超时方法,trylock(long timeout, TimeUnit unit)
- lockInterruptibly() 放代码块中,调用interrupt() 方法可以中断
4)加锁是否公平
- synchronized:非公平锁
- ReentrantLock:默认非公平锁,构造函数可以传递boolean值,true为公平锁,false为非公平锁
5)锁绑定多个条件Condition
- synchronized:没有,要么随机,要么全部唤醒
- ReentrantLock:用来实现分组唤醒需要唤醒的线程,可以精确唤醒,而不是像synchronized那样,要么随机,要么全部唤醒
针对刚刚提到的区别的第5条,场景:3.线程间定制化通信
章节
14.ThreadPool线程池
连接池:预先创建好连接,每次操作从连接池中取到连接,用完之后放回连接池中,供其它操作使用。
作用:减少每次操作都创建和关闭,可以提高连接的复用性。
而线程池与连接池类似的。
1.线程池是什么?
线程池:一种线程使用模式,线程过多会带来调度开销,进而影响缓存局部和整体性能,而线程池维护着多个线程,等待着监督管理者分配可并发执行的任务,避免了在处理短时间任务时创建与销毁线程的代价,线程池不仅能够保证内核的充分利用,还能防止过度调度。
例子:10年前单核CPU电脑,假的多线程,像马戏团小丑玩多个球,CPU需要来回切换,现在是多核电脑,多个线程各自跑在独立的CPU上,不用切换效率高。
2.为什么要用线程池?
线程池的优势:线程池做的工作只要是控制运行的线程数量,处理过程中将任务放入队列,然后在线程创建后启动这些任务,如果线程数量超过了最大数量,超出数量的线程排队等待,等其他线程执行完毕,再从队列中取出任务来执行。
它的主要特点为:线程复用、控制最大并发数、管理线程;线程池中的任务是放入到阻塞队列中的
线程池的好处:
- 降低资源消耗:通过重复利用已创建的线程降低线程创建和销毁造成的消耗。
- 提高响应速度:当任务到达时,任务可以不需要等待线程创建就能立即执行。
- 提高线程的可管理性:线程是稀缺资源,如果无限制的创建,不仅会消耗系统资源,还会降低系统的稳定性,使用线程池可以进行统一的分配,调优和监控。
3.架构说明
Java中的线程池是通过Executor框架实现的,该框架中用到了Executor,Executors(代表工具类),ExecutorService,ThreadPoolExecutor这几个类
14.1.Executors三种线程池创建方式
- 一池N线程:Executors.newFixedThreadPool(int),执行长期的任务,性能好很多
- 一池一线程:Executors.newSingleThreadExecutor(),一个任务一个任务执行的场景
- 一池可扩容线程:Executors.newCachedThreadPool(),执行很多短期异步的小程序或者负载较轻的服务器
三种线程创建方式,底层都是使用ThreadPoolExecutor类创建线程。
银行办理业务代码演示:
public class ThreadPoolDemo1 {
public static void main(String[] args) {
// Array Arrays(辅助工具类)
// Collection Collections(辅助工具类)
// Executor Executors(辅助工具类)
//一池5线程
//ExecutorService threadPool1 = Executors.newFixedThreadPool(5);//5个窗口
//一池一线程
//ExecutorService threadPool2 = Executors.newSingleThreadExecutor();//一个窗口
//一池可扩容线程
ExecutorService threadPool3 = Executors.newCachedThreadPool();
try {
//10个顾客
for (int i = 1; i <= 10; i++) {
threadPool3.execute(() -> {
System.out.println(Thread.currentThread().getName() + " 办理业务");
});
}
} catch (Exception e) {
e.printStackTrace();
} finally {
//用池化技术,一定要记得关闭
threadPool3.shutdown();
}
}
}
底层实现:
我们通过查看源码,点击了Executors.newSingleThreadExecutor 和 Executors.newFixedThreadPool能够发现底层都是使用了ThreadPoolExecutor
我们可以看到线程池的内部,还使用到了LinkedBlockingQueue 链表阻塞队列
同时在查看Executors.newCacheThreadPool 看到底层用的是 SynchronousBlockingQueue阻塞队列
最后查看一下,完整的三个创建线程的方法
14.2.ThreadPoolExecutor七个参数
public ThreadPoolExecutor(int corePoolSize,
int maximumPoolSize,
long keepAliveTime,
TimeUnit unit,
BlockingQueue workQueue,
ThreadFactory threadFactory,
RejectedExecutionHandler handler) {
if (corePoolSize < 0 ||
maximumPoolSize <= 0 ||
maximumPoolSize < corePoolSize ||
keepAliveTime < 0)
throw new IllegalArgumentException();
if (workQueue == null || threadFactory == null || handler == null)
throw new NullPointerException();
this.acc = System.getSecurityManager() == null ?
null :
AccessController.getContext();
this.corePoolSize = corePoolSize;
this.maximumPoolSize = maximumPoolSize;
this.workQueue = workQueue;
this.keepAliveTime = unit.toNanos(keepAliveTime);
this.threadFactory = threadFactory;
this.handler = handler;
}
- int corePoolSize:核心线程数,线程池中的常驻线程数
- 在创建线程池后,当有请求任务来之后,就会安排池中的线程去执行请求任务,近似理解为今日当值线程
- 当线程池中的线程数目达到corePoolSize后,就会把到达的任务放到缓存队列中
- int maximumPoolSize:线程池能够容纳同时执行的最大线程数,此值必须大于等于1
- 相当有扩容后的线程数,这个线程池能容纳的最多线程数
- long keepAliveTime:多余的空闲线程存活时间
- 当线程池数量超过corePoolSize时,当空闲时间达到keepAliveTime值时,多余的空闲线程会被销毁,直到只剩下corePoolSize个线程为止
- 默认情况下,只有当线程池中的线程数大于corePoolSize时,keepAliveTime才会起作用
- TimeUnit unit:keepAliveTime的单位
- BlockingQueue
workQueue:阻塞队列,核心线程数都用完后,会放到阻塞队列中等待,被提交的但未被执行的任务 - LinkedBlockingQueue:链表阻塞队列
- SynchronousBlockingQueue:同步阻塞队列
- ThreadFactory threadFactory:生成线程池中工作线程的线程工厂,用于创建线程的,一般用默认即可
- RejectedExecutionHandler handler:拒绝策略,表示当队列满了并且工作线程大于线程池的最大线程数(maximumPoolSize)时,新来的线程会被拒绝策略处理
- 当营业窗口和阻塞队列中都满了时候,就需要设置拒绝策略
14.3.线程池底层工作流程
说明:
- 在创建了线程池后,等待提交过来的任务请求
- 当调用execute()方法添加一个请求任务时,线程池会做出如下判断
1). 如果正在运行的线程池数量小于corePoolSize,那么马上创建线程运行这个任务
2). 如果正在运行的线程数量大于或等于corePoolSize,那么将这个任务放入队列
3). 如果这时候队列满了,并且正在运行的线程数量还小于maximumPoolSize,那么还是创建非核心线程来运行这个任务
4). 如果队列满了并且正在运行的线程数量大于或等于maximumPoolSize,那么线程池会启动饱和拒绝策略来执行 - 当一个线程完成任务时,它会从队列中取下一个任务来执行
- 当一个线程无事可做操作一定的时间(keepAliveTime)时,线程池会判断:
1). 如果当前运行的线程数大于corePoolSize,那么这个线程就被停掉
2). 所以线程池的所有任务完成后,它会最终收缩到corePoolSize的大小
以顾客去银行办理业务为例,谈谈线程池的底层工作原理
- 最开始假设来了两个顾客,因为corePoolSize为2,因此这两个顾客直接能够去窗口办理
- 后面又来了三个顾客,因为corePool已经被顾客占用了,因此只有去候客区,也就是阻塞队列中等待
- 后面的人又陆陆续续来了,候客区可能不够用了,因此需要申请增加处理请求的窗口,这里的窗口指的是线程池中的线程数,以此来解决线程不够用的问题
- 假设受理窗口已经达到最大数,并且请求数还是不断递增,此时候客区和线程池都已经满了,为了防止大量请求冲垮线程池,已经需要开启拒绝策略
- 临时增加的线程会因为超过了最大存活时间,就会销毁,最后从最大数削减到核心数
JVM内置的四种拒绝策略,都实现了RejectedExecutionHandler接口
- AbortPolicy(默认):直接抛出RejectedExecutionException异常阻止系统正常运行。
- CallerRunsPolicy:“调用者运行”一种调节机制,该策略既不会抛弃任务,也不会抛出异常,而是将某些任务回退到调用者,从而降低新任务的流量。(谁让你来的找谁去,有点不讲理)
- DiscardOldestPolicy:抛弃队列中等待最久的任务,然后把当前任务加入队列中尝试再次提交当前任务。
- DicardPolicy:该策略默默地抛弃无法处理的任务,不予任何处理也不抛出异常。如果允许任务丢失,这是最好的一种策略。
14.4.自定义线程池(实际开发)
线程池创建的方法有:固定数的,单一的,可变的,那么在实际开发中,应该使用哪个?
我们一个都不用,在生产环境中是使用自己自定义的
实际开发中,Executors三种线程创建方式都不使用,为什么不允许使用Executors的方法手动创建线程池?
阿里巴巴Java开发手册(并发控制这章):
- 【强制】线程资源必须通过线程池提供,不允许在应用中自行显式创建线程
- 使用线程池的好处是减少在创建和销毁线程上所消耗的时间以及系统资源的开销,解决资源不足的问题,如果不使用线程池,有可能造成系统创建大量同类线程而导致消耗完内存或者“过度切换”的问题
- 【强制】线程池不允许使用Executors去创建,而是通过
ThreadPoolExecutor
的方式,这样的处理方式让写的同学更加明确线程池的运行规则,避免资源耗尽的风险。- 说明:Executors返回的线程池对象的弊端如下:
- 1).FixedThreadPool和SingleThreadPool:
允许的请求队列长度为Integer.MAX_VALUE,可能会堆积大量的请求,从而导致OOM。 - 2).CachedThreadPool和SheduledThreaPool:
允许的创建线程数量为Integer.MAX_VALUE,可能会创建大量的线程,从而导致OOM。
- 1).FixedThreadPool和SingleThreadPool:
- 说明:Executors返回的线程池对象的弊端如下:
自定义线程池代码演示:
下面我们创建了一个 核心线程数为2,最大线程数为5,并且阻塞队列数为3的线程池,模拟10个用户办理业务
1.采用AbortPolicy默认拒绝策略
//自定义线程池创建
public class ThreadPoolDemo2 {
public static void main(String[] args) {
ThreadPoolExecutor threadPool = new ThreadPoolExecutor(
2,
5,
2L,
TimeUnit.SECONDS,
new ArrayBlockingQueue<>(3),
Executors.defaultThreadFactory(),
new ThreadPoolExecutor.AbortPolicy()
);
try {
//10个顾客
for (int i = 1; i <= 10; i++) {
threadPool.execute(() -> {
System.out.println(Thread.currentThread().getName() + " 办理业务");
});
}
} catch (Exception e) {
e.printStackTrace();
} finally {
//关闭
threadPool.shutdown();
}
}
}
结果:拒绝策略超出线程池处理能力,抛出异常java.util.concurrent.RejectedExecutionException
触发条件是,请求的线程大于 阻塞队列大小 + 最大线程数 = 8 的时候,也就是说第9个线程来获取线程池中的线程时,就会抛出异常从而报错退出。
2.采用CallerRunsPolicy拒绝策略
采用CallerRunsPolicy拒绝策略,也称为回退策略,就是把任务丢回原来的请求开启线程着,我们看运行结果
输出的结果里面出现了main线程,因为线程池出发了拒绝策略,把任务回退到main线程,然后main线程对任务进行处理
3.采用DiscardPolicy拒绝策略
采用DiscardPolicy拒绝策略会,线程池会自动把后面的任务都直接丢弃,也不报异常,当任务无关紧要的时候,可以采用这个方式
4.采用DiscardOldestPolicy拒绝策略
这个策略和刚刚差不多,会把最久的队列中的任务替换掉
14.5.如何合理配置线程池
生产环境中如何配置 corePoolSize 和 maximumPoolSize,这个是根据具体业务来配置的,分为CPU密集型和IO密集型
-
CPU密集型
CPU密集的意思是该任务需要大量的运算,而没有阻塞,CPU一直全速运行
CPU密集任务只有在真正的多核CPU上才可能得到加速(通过多线程),而在单核CPU上,无论你开几个模拟的多线程该任务都不可能得到加速,因为CPU总的运算能力就那些
CPU密集型任务配置尽可能少的线程数量:
一般公式:CPU核数 + 1个线程数 -
IO密集型
由于IO密集型任务线程并不是一直在执行任务,则应配置尽可能多的线程,如 CPU核数 * 2
IO密集型,即该任务需要大量的IO操作,即大量的阻塞
在单线程上运行IO密集型的任务会导致浪费大量的CPU运算能力花费在等待上,所以IO密集型任务中使用多线程可以大大的加速程序的运行,即使在单核CPU上,这种加速主要就是利用了被浪费掉的阻塞时间。
IO密集时,大部分线程都被阻塞,故需要多配置线程数:
参考公式:CPU核数 / (1 - 阻塞系数) 阻塞系数在0.8 ~ 0.9左右
例如:8核CPU:8/ (1 - 0.9) = 80个线程数
15.Fork/Join分支合并框架
Fork:把一个复杂任务进行拆分,大事化小
Join:把分拆任务的结果进行合并
例子:1+2+3+...+100
拆分1:1+2+3+...+20
拆分2:21+22+...+40
拆分3:41+42+...+60
...
拆分n:81+82+...+100
合并:拆分1+拆分2+拆分3+...+拆分n
Fork/Join方法:
计算斐波那契数列的任务:
class Fibonacci extends RecursiveTask {
final int n;
Fibonacci(int n) { this.n = n; }
Integer compute() {
if (n <= 1)
return n;
Fibonacci f1 = new Fibonacci(n - 1);
f1.fork();
Fibonacci f2 = new Fibonacci(n - 2);
return f2.compute() + f1.join();
}
}
15.1.案例实现
例子:从1+2+...+100,要求相加两个数,差值不能超过10
class MyTask extends RecursiveTask {
//拆分差值不能超过10,计算10以内运算
public static final Integer VALUE = 10;
private int begin; //拆分开始值
private int end; //拆分结束值
private int result; //返回结果
//创建有参构造
public MyTask(int begin, int end) {
this.begin = begin;
this.end = end;
}
//拆分和合并过程
@Override
protected Integer compute() {
//判断相加两个数值是否大于10
if (end - begin <= VALUE) {
//相加操作
for (int i = begin; i <= end; i++) {
result += i;
}
} else {//进一步拆分
//获取中间值
int middle = (begin + end) / 2;
//拆分左边
MyTask myTask01 = new MyTask(begin, middle);
//拆分右边
MyTask myTask02 = new MyTask(middle + 1, end);
//调用方法拆分
myTask01.fork();
myTask02.fork();
//合并结果
result = myTask01.join() + myTask02.join();
}
return result;
}
}
public class ForkJoinDemo {
public static void main(String[] args) throws ExecutionException, InterruptedException {
//创建MyTask对象
MyTask myTask = new MyTask(1, 100);
//创建分支合并池对象
ForkJoinPool forkJoinPool = new ForkJoinPool();
ForkJoinTask forkJoinTask = forkJoinPool.submit(myTask);
//获取最终合并之后结果
Integer result = forkJoinTask.get();
System.out.println(result);
//关闭池对象
forkJoinPool.shutdown();
}
}
16.CompletableFuture异步回调
代码演示:
public class CompletableFutureDemo {
public static void main(String[] args) throws ExecutionException, InterruptedException {
//异步调用 没有返回值
CompletableFuture completableFuture1 = CompletableFuture.runAsync(() -> {
System.out.println(Thread.currentThread().getName() + "completableFuture1");
});
completableFuture1.get();
//异步调用 有返回值
CompletableFuture completableFuture2 = CompletableFuture.supplyAsync(() -> {
System.out.println(Thread.currentThread().getName() + "completableFuture2");
//模拟异常
//int age = 10 / 0;
return 1024;
});
completableFuture2.whenComplete((t, u) -> {
System.out.println("---t=" + t);
System.out.println("---u=" + u);
}).get();
}
}
```k