多线程高并发1
一、线程安全的讨论
1、CPU多核缓存架构
(1)CPU的处理速度是最快的,内存的速度次之,硬盘速度最慢。
(2)在CPU处理内存数据中,内存运行速度太慢,就会拖累CPU的速度。为了解决这样的问题,CPU设计了多级缓存策略。
(3)CPU分为三级缓存:每个核独有L1,L2缓存,L3缓存是多核公用的。主存是多个CPU之间公用的。
(4)CPU查找数据的顺序为: CPU -> L1 -> L2 -> L3 -> 内存 -> 硬盘
(5)CPU每次读取一个数据,并不是仅仅读取这个数据本身,而是会读取与它相邻的64个字节
的数据,称之为缓存行
。因为CPU认为,我使用了这个变量,很快就会使用与它相邻的数据,这是计算机的局部性原理。这样,就不需要每次都从主存中读取数据了。
2、线程资源争夺问题
(1)除了增加高速缓存
之外,为了使处理器内部的运算单元尽量被充分利用。处理器可能会对输入的代码进行乱序执行
,优化处理器会在计算之后将乱序执行的结果进行重组
,保证该结果与顺序执行的结果是一致的,但并不保证程序中各个语句的先后执行顺序与输入代码中的顺序一致。因此如果存在一个计算任务,依赖于另外一个依赖任务的中间,结果那么顺序性不能靠代码的先后顺序来保证。 Java虚拟机的即时编译器中也有指令重排
的优化。
(2)举例:比如4个人顺序写下“新年快乐”四个字,只能上一个写完后下一个才能写,现在乱序执行
,可以让4个人可以不分先后的写,最后再将结果重写排成“新年快乐”。
3. JMM:java内存模型
曾经试图定义一种Java内存模型,来屏蔽各种硬件和操作系统的内存访问之间的差异,以实现让Java程序在各种平台上都能达到一致的内存访问效果
4. 指令重排
(1)有一个经典的as-if-serial语义,计算机会安装该语义对指令进行优化,其目的是不管怎么重排序,程序的执行结果不能被改变
(2)在单线程
中,如果操作之间不存在数据依赖关系,很可能被指令重排,如果存在数据依赖,则不会被重排。如下:前2条可能会被指令重排,但是后2条存在数据依赖,不能被指令重排
//初始化
x = 0 ; y = 0 ; z = 0 ; v = 0 ;
//执行
(1)x = 1;
(2)y = 1;
(3)z = x;
(4)v = y;
那如果以上指令分给2个线程
执行
//初始化
x = 0 ; y = 0 ; z = 0 ; v = 0 ; w = 0;
//线程1执行
(1)x = 1;
(4)v = y;
//线程2执行
(2)y = 1;
(3)z = x;
那么结果会怎么样呢?
答案是4种结果都可能出现。
因为在每一个线程内,他们的操作之间不存在数据依赖关系,出现指令重排导致乱序的问题。那结果和顺序执行是不一样的。
解决指令重排的方法是使用内存屏障
,使用关键字volatile
5. 内存屏障
(1)定义:内存屏障是在我们的读写操作之前加入一条指令,当CPU碰到这条指令后必须等到前边的指令执行完成才能继续执行下一条指令。
(2)方法:使用volatile
关键字来保证一个变量在一次读写操作时的避免指令重排。
6. 可见性问题
如下
按理来说,主线程2秒之后,修改了flag,那么子线程会退出while循环,打印出"凉拌辣芒果",但是结果是控制台什么都没有输出,原因是什么?
正是因为多线程之间可见性的问题。
(1)在单线程环境中,如果向某个变量写入某个值,在没有其他写入操作的影响下,那么总能取到写入的那个值。
(2)在多线程环境中,当读操作和写操作在不同的线程中执行时(这里以主线程修改flag和子线程读取flag为例),不满足多线程之间的可见性,所以为了确保多个线程之间对内存写入操作的可见性,必须使用同步机制。
(3)子线程一直在高速缓存中
读取flag的值,不能感知main线程已经修改了flag的值而退出循环,这就是可见性的问题,使用volatile
关键字可以解决这个问题。
(4)volatile能强制对改变量的读写直接在主存中操作
。写操作时,立刻强制刷在主存,并且将其他缓存区域的值设置为不可用
7. happens-before语义
- 在Java 规范提案中为让大家理解内存可见性的这个概念,提出了happens-before的概念来阐述操作之间的内存可见性。对应Java程序员来说,理解happens-before是理解JMM的关键。JMM这么做的原因是:
程序员对于这两个操作是否真的被重排序并不关心,程序员关心的是程序执行时的语义不能被改变(即执行结果不能被改变)
。因此,happens-before关系本质上和as-if-serial语义是一回事。 - as-if-serial语义保证
单线程内
程序的执行结果不被改变,happens-before关系保证正确同步的多线程程序
的执行结果不被改变。
8.volatile 关键字
- 禁止指令重排
- 内存的可见性
9.线程争抢
多个线程同时争抢相同的公共资源
就是线程争抢,线程争抢会造成数据安全问题。解决线程争抢问题的最好的方案就是加锁
。使用关键字synchronized
二、线程安全的实现方法
1. 数据不可变
(1)一切不可变的对象(immutable)一定是线程安全的
(2)比如final关键字修饰的基础数据类型
(3)Java字符串
2. 互斥同步(阻塞同步)
(1)同步是指在多个线程并发访问共享数据时,保证共享数据在同一时刻只被一个线程使用。
(2)互斥是因、同步是果。互斥是方法,同步是目的。
(3)互斥方法:synchronized关键字
; 还可以使用ReentrantLock工具类
实现。
(4)问题:进行线程阻塞和唤醒带来的性能开销
3. 非阻塞同步
通俗的说,就是不管有没有风险,直接进行操作
,如果没有其他线程征用共享数据,那就直接成功,如果共享数据确实被征用产生了冲突,那就再进行补偿策略
,常见的补偿策略就是不断的重试,直到出现没有竞争的共享数据为止,这种乐观并发策略
的实现,不再需要把线程阻塞挂起,因此同步操作也被称为非阻塞同步
,这种措施的代码也常常被称之为无锁编程
,也就是咱们说的自旋
4. 无同步方案
(1)多个线程需要共享数据,但是这些数据又可以在单独的线程当中计算,得出结果,而不被其他的线程所影响,如果能保证这一点,我们就可以把共享数据的可见范围限制在一个线程之内,这样就无需同步
(2)ThreadLocal类
提供了线程内存储变量的能力,这些变量不同之处在于每一个线程读取的变量是对应的互相独立的。通过get和set方法就可以得到当前线程对应的值。
3. 并发编程的三大特性
1. 原子性
(1)原子操作定义:原子操作可以是一个步骤,也可以是多个操作步骤,但是其顺序不可以被打乱,也不可以被切割而只执行其中的一部分(不可中断性)。将整个操作视为一个整体是原子性的核心特征。原子性不仅仅是多行代码,也可能是多条指令。
(2)存在竞争条件,线程不安全,需要转变原子操作才能安全。
(3)方式:上锁、循环CAS
2. 可见性
详见:Java初学笔记22-【线程】
3. 有序性
详见:Java初学笔记22-【线程】
volatile
:可以保证可见性和有序行。但是无法保证原子性。
因为假设有1000个线程,每个线程都是可以从主存中读取数据,但是当第1个线程读取完了之后,其他的999个线程都是可以从主存中读取该数据,这就是问题所在。
synchronized
和Lock
:可以保证原子性、可见性、有序性
三、JUC并发编程包
所谓的JUC指的是:java.util.concurrent这个包
1. 原子类 Atomic类
(1)多线程环境下执行,Atomic 类,是具有原子操作特征的类。
(2)JUC 包中的原子类
基本类型 | 数组类型 |
---|---|
AtomicInteger:整形原子类 | AtomicIntegerArray:整形数组原子类 |
AtomicLong:长整型原子类 | AtomicLongArray:长整形数组原子类 |
AtomicBoolean:布尔型原子类 | AtomicReferenceArray:引用类型数组原子类 |
引用类型 | 对象的属性修改类型** |
---|---|
AtomicReference:引用类型原子类 | AtomicIntegerFieldUpdater:原子更新整形字段的更新器 |
AtomicStampedReference:原子更新引用类型里的字段原子类 | AtomicLongFieldUpdater:原子更新长整形字段的更新器 |
AtomicMarkableReference :原子更新带有标记位的引用类型 | AtomicStampedReference:原子更新带有版本号的引用类型。该类将整数值与引用关联起来,可用于解决原子的更新数据和数据的版本号,以及解决使用 CAS 进行原子更新时可能出现的 ABA 问题 |
2. 线程池
-
为什么要使用线程池?
(1) 降低资源消耗。 通过重复利用已创建的线程降低线程创建和销毁造成的消耗。
(2) 提高响应速度。 当任务到达时,任务可以不需要等到线程创建就能立即执行。
(3) 提高线程的可管理性。 线程是稀缺资源,如果无限制的创建,不仅会消耗系统资源,还会降低系统的稳定性,使用线程池可以进行统一的分配、调优和监控。 -
jdk自带的四种线程池
线程池类名 | 特定 |
---|---|
newCachedThreadPool | 创建一个线程数量可变的 可缓存线程池。当有任务来的时候创建对应大小的线程在线程池中;如果线程池长度 > 任务处理需要,可灵活回收空闲线程,若无可回收,则新建线程。 |
newSingleThreadExecutor | 创建只有一个线程 的线程池,它只会用唯一的工作线程来执行任务,保证所有任务按照指定顺序执行。 |
newFixedThreadPool | 创建固定数量线程 的线程池,可控制线程最大并发数,超出的线程会在队列中等待。 |
newScheduledThreadPool | 创建一个固定数量线程 的线程池,支持定时及周期性任务执行。 |
3. 线程同步
1. CountDownLatch (倒计时器)
等多个线程执行完毕,再让某个线程执行。 CountDownLatch的典型用法就是:某一线程在开始运行前先等待n个线程执行完毕。
使用方法如下:
(1)将 CountDownLatch 的计数器初始化为n :new CountDownLatch(n),
(2)每当一个任务线程执行完毕,就将计数器减1countdownlatch.countDown()
,当计数器的值变为0时,在CountDownLatch上 await()的线程就会被唤醒。
一个典型应用场景就是启动一个服务时,主线程需要等待多个组件加载完毕,之后再继续执行。
public class CountDownLatchTest {
public static void main(String[] args) throws InterruptedException {
ExecutorService pool = Executors.newCachedThreadPool();
CountDownLatch countDownLatch = new CountDownLatch(3);
Runnable task1 = () -> {
ThreadUtils.sleep(new Random().nextInt(5000));
System.out.println("计算山西分公司的账目");
countDownLatch.countDown();
};
Runnable task2 = () -> {
ThreadUtils.sleep(new Random().nextInt(5000));
System.out.println("计算北京分公司的账目");
countDownLatch.countDown();
};
Runnable task3 = () -> {
ThreadUtils.sleep(new Random().nextInt(5000));
System.out.println("计算上海分公司的账目");
countDownLatch.countDown();
};
pool.submit(task1);
pool.submit(task2);
pool.submit(task3);
countDownLatch.await();
System.out.println("计算总账!");
}
}
CountDownLatch是一次性的,计数器的值只能在构造方法中初始化一次,之后没有任何机制再次对其设置值,
当CountDownLatch使用完毕后,它不能再次被使用
。
2. CyclicBarrier(循环栅栏)
(1)CyclicBarrier 的字面意思是可循环使用(Cyclic)的屏障(Barrier)。它要做的事情是,让一组线程到达一个屏障(也可以叫同步点)时被阻塞,直到最后一个线程到达屏障时,屏障才会开门,所有被屏障拦截的线程才会继续干活。就像10个人做客吃饭,人齐了才能动筷子,吃完可以去下一桌吃,当时都得等人齐了才能吃。
(2)可以重复使用
public class CyclicBarrierTest {
public static void main(String[] args) throws InterruptedException {
ExecutorService pool = Executors.newCachedThreadPool();
// 计算总账的主线程
Runnable main = () -> System.out.println("计算总账!");
CyclicBarrier cyclicBarrier = new CyclicBarrier(3,main);
Runnable task1 = () -> {
ThreadUtils.sleep(new Random().nextInt(5000));
System.out.println("计算山西分公司的账目");
try {
cyclicBarrier.await();
} catch (InterruptedException e) {
e.printStackTrace();
} catch (BrokenBarrierException e) {
e.printStackTrace();
}
};
Runnable task2 = () -> {
ThreadUtils.sleep(new Random().nextInt(5000));
System.out.println("计算北京分公司的账目");
try {
cyclicBarrier.await();
} catch (InterruptedException e) {
e.printStackTrace();
} catch (BrokenBarrierException e) {
e.printStackTrace();
}
};
Runnable task3 = () -> {
ThreadUtils.sleep(new Random().nextInt(5000));
System.out.println("计算上海分公司的账目");
try {
cyclicBarrier.await();
} catch (InterruptedException e) {
e.printStackTrace();
} catch (BrokenBarrierException e) {
e.printStackTrace();
}
};
pool.submit(task1);
pool.submit(task2);
pool.submit(task3);
// 重复利用
ThreadUtils.sleep(5000);
cyclicBarrier.reset();
System.out.println("-------------reset-----------");
pool.submit(task1);
pool.submit(task2);
pool.submit(task3);
}
}
3. Semaphore(信号量)
(1)可以设置参数,控制同时访问的个数
(2)例如申明了一个只有5个许可的Semaphore,而有100个线程要访问这个资源,通过acquire()和release()获取和释放访问许可。
public class SemaphoreTest {
public static void main(String[] args) throws InterruptedException {
final Semaphore semaphore = new Semaphore(5);
ExecutorService exec = Executors.newCachedThreadPool();
for (int index = 0; index < 100; index++) {
Runnable run = () -> {
try {
// 获取许可
semaphore.acquire();
System.out.println("开进一辆车...");
Thread.sleep((long) (Math.random() * 5000));
// 访问完后,释放
semaphore.release();
System.out.println("离开一辆车...");
} catch (InterruptedException e) {
e.printStackTrace();
}
};
exec.execute(run);
}
exec.shutdown();
}
}
部分资料参考:元动力 JAVA多线程入门