2.线程安全问题
2.1 什么是线程安全?
当多个线程访问更改共享变量时候,就会出现线程安全问题。
1. 什么是线程安全问题?
多线程操作共享变量,导致访问数据出问题。
2. 出现线程安全问题的条件
有多个线程
有共享数据
其中一个线程修改了共享数据
2.1.1 模拟售票案例
/**
* 需求:我们来模拟电影院的售票窗口,实现多个窗口同时卖“速度与激情8”这场电影票(多个窗口一起卖这100张票)。
* 分析: 多个窗口相当于多线程, 每个窗口做的事情是一样的,卖100张票(任务放到Runnable中)
*/
public class C1_线程安全_售票案例 {
// 总票数
static int ticket = 100;
public static void main(String[] args) {
Runnable runnable = () ->{
// 循环买票
while (true){
try {
Thread.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
if (ticket > 0) {
ticket--;
System.out.println(Thread.currentThread().getName()+
"卖了一张票,剩余:" + ticket);
} else {
// 票没了
break;
}
}
};
// 创建3个线程
Thread t1 = new Thread(runnable,"窗口1");
Thread t2 = new Thread(runnable,"窗口2");
Thread t3 = new Thread(runnable,"窗口3");
t1.start();
t2.start();
t3.start();
}
}
测试结果:
这也是超买的的现象,为什么会出现这种情况呢?要从jvm的内存模型设计开始。
2.2 并发编程需要处理的问题
问题(一)死锁问题
并发编程的目的是为了让程序运行得更快,但是,并不是启动更多的线程就能让程序最大限度地并发执行。在进行并发编程时,如果希望通过多线程执行任务让程序运行得更快,会面临非常多的挑战,比如上下文切换的问题、死锁的问题,本章会介绍几种并发编程的挑战以及解决方案。
描述
锁是个非常有用的工具,运用场景非常多,因为它使用起来非常简单,而且易于理解。但同时它也会带来一些困扰,那就是可能会引起死锁,一旦产生死锁,就会造成系统功能不可用。让我们先来看一段代码,这段代码会引起死锁,使线程t 1和线程t 2互相等待对方释放锁。
演示
什么是死锁? 多线程竞争共享资源,导致线程相互等待,程序无法向下执行
/*
目标:学习死锁的概念和解决死锁
*/
public class DeadLock {
private static Object objA = new Object();
private static Object objB = new Object();
public static void main(String[] args) {
Thread t1 = new Thread(()->{
synchronized (objA){
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("AAAAAAA");
synchronized (objB){
System.out.println("BBBBBBB");
}
}
});
Thread t2 = new Thread(()->{
synchronized (objB){
System.out.println("CCCCCCC");
synchronized (objA){
System.out.println("DDDDDDD");
}
}
});
t1.start();
t2.start();
}
}
上面的代码只是演示死锁的场景,在现实中你可能不会写出这样的代码。但是,在一些更为复杂的场景中,你可能会遇到这样的问题,比如t1拿到锁之后,因为一些异常情况没有释放锁(死循环)。又或者是t1拿到一个数据库锁,释放锁的时候抛出了异常,没释放掉。
一旦出现死锁,业务是可感知的,因为不能继续提供服务了。
查看线程执行情况
如何避免死锁?
现在我们介绍避免死锁的几个常见方法:
- 避免一个线程同时获取多个锁。
- 避免一个线程在锁内同时占用多个资源,尽量保证每个锁只占用一个资源。
- 尝试使用定时锁,使用 Lock .tryLock (timeout )来替代使用内部锁机制。
- 对于数据库锁,加锁和解锁必须在一个数据库连接里,否则会出现解锁失败的情况。
小结
1.什么是死锁:
多线程竞争共享资源,导致线程相互等待,程序无法向下执行
2.死锁产生的条件:
A.有多个线程
B.有多把锁
C.有同步代码块嵌套
3.如何避免死锁:
干掉其中一个条件即可
问题(二)上下文切换
多线程一定快吗?
测试代码
下面的代码演示串行和并发执行并累加操作的时间,请分析:下面的代码并发执行一定比串行执行快吗?
public class ConcurrencyTest {
private static final long count = 100000000;
public static void main(String[] args) throws InterruptedException {
concurrency();
serial();
}
private static void concurrency() throws InterruptedException {
long start = System.currentTimeMillis();
Thread thread = new Thread(new Runnable() {
@Override
public void run() {
int a = 0;
for (long i = 0; i < count; i++) {
a += 5;
}
}
});
thread.start();
int b = 0;
for (long i = 0; i < count; i++) {
b--;
}
long time = System.currentTimeMillis() - start;
thread.join();
System.out.println("concurrency :" + time + "ms,b=" + b);
}
private static void serial() {
long start = System.currentTimeMillis();
int a = 0;
for (long i = 0; i < count; i++) {
a += 5;
}
int b = 0;
for (long i = 0; i < count; i++) {
b--;
}
long time = System.currentTimeMillis() - start;
System.out.println("serial:" + time + "ms,b=" + b + ",a=" + a);
}
}
测试结果
上述问题的答案是"不一定",测试结果如表所示:
当并发执行累加操作不超过百万次时,速度会比串行执行累加操作要慢。那么,为什么并发执行的速度会比串行慢呢?这是因为线程有创建和上下文切换的开销。
上下文切换
即使是单核处理器也支持多线程执行代码,CPU 通过给每个线程分配CPU 时间片来实现这个机制。时间片是CPU 分配给各个线程的时间,因为时间片非常短,所以CPU 通过不停地切换线程执行,让我们感觉多个线程是同时执行的,时间片一般是几十毫秒(ms)。
CPU 通过时间片分配算法来循环执行任务,当前任务执行一个时间片后会切换到下一个任务。但是,在切换前会保存上一个任务的状态,以便下次切换回这个任务时,可以再加载这个任务的状态。所以任务从保存到再加载的过程就是一次上下文切换。
这就像我们同时读两本书,当我们在读一本英文的技术书时,发现某个单词不认识,于是便打开中英文字典,但是在放下英文技术书之前,大脑必须先记住这本书读到了多少页的第多少行,等查完单词之后,能够继续读这本书。这样的切换是会影响读书效率的,同样上下文切换也会影响多线程的执行速度。
如何减少上下文切换
减少上下文切换的方法有无锁并发编程、CAS算法、使用最少线程和使用协程。
无锁并发编程:多线程竞争锁时,会引起上下文切换,所以多线程处理数据时,可以用一些办法来避免使用锁,如将数据的ID按照Hash 算法取模分段,不同的线程处理不同段的数据。
CAS算法:Java的Atomic包使用CAS算法来更新数据,而不需要加锁。
使用最少线程:避免创建不需要的线程,比如任务很少,但是创建了很多线程来处理,这样会造成大量线程都处于等待状态。
协程:在单线程里实现多任务的调度,并在单线程里维持多个任务间的切换。
资源限制的挑战
-
什么是资源限制?
资源限制是指在进行并发编程时,程序的执行速度受限于计算机硬件资源或软件资源。例如,服务器的带宽只有2Mb/s,某个资源的下载速度是1Mb/ s每秒,系统启动10个线程下载资源,下载速度不会变成10Mb/ s,所以在进行并发编程时,要考虑这些资源的限制。硬件资源限制有带宽的上传/下载速度、硬盘读写速度和CPU 的处理速度。软件资源限制有数据库的连接数和socket连接数等。 -
资源限制引发的问题
在并发编程中,将代码执行速度加快的原则是将代码中串行执行的部分变成并发执行,但是如果将某段串行的代码并发执行,因为受限于资源,仍然在串行执行,这时候程序不仅不会加快执行,反而会更慢,因为增加了上下文切换和资源调度的时间。例如,之前看到一段程序使用多线程在办公网并发地下载和处理数据时,导致CPU 利用率达到100%,几个小时都不能运行完成任务,后来修改成单线程,一个小时就执行完成了。
-
如何解决资源限制的问题?
对于硬件资源限制,可以考虑使用集群并行执行程序。既然单机的资源有限制,那么就让程序在多机上运行。比如使用ODPS、Hadoop或者自己搭建服务器集群,不同的机器处理不同的数据。
对于软件资源限制,可以考虑使用资源池将资源复用。比如使用连接池将数据库和Socket连接复用,或者在调用对方webservice接口获取数据时,只建立一个连接。 -
在资源限制情况下进行并发编程
如何在资源限制的情况下,让程序执行得更快呢?方法就是,根据不同的资源限制调整程序的并发度,比如下载文件程序依赖于两个资源——带宽和硬盘读写速度。有数据库操作时,涉及数据库连接数,如果SQL 语句执行非常快,而线程的数量比数据库连接数大很多,则某些线程会被阻塞,等待数据库连接。
小结
刚才分析了在进行并发编程时,大家可能会遇到的几个挑战,并给出了一些解决建议。如果我们的并发程序写得不严谨,在并发下如果出现问题,定位起来会比较耗时和棘手。
所以,强烈建议多使用JDK并发包提供的并发容器和工具类来解决并发问题,因为这些类都已经通过了充分的测试和优化,均可解决了上面提到的几个挑战。
2.3 JMM内存模型
JMM内存模型(一)CPU体系结构之cache
什么是CPU缓存?
CPU缓存(Cache Memory)位于CPU与内存之间的临时存储器,它的容量比内存小但交换速度快。在缓存中的数据是内存中的一小部分,但这一小部分是短时间内CPU即将访问的,当CPU调用大量数据时,就可避开内存直接从缓存中调用,从而加快读取速度。
在CPU中加入缓存是一种高效的解决方案,这样整个内存储器(缓存+内存)就变成了既有缓存的高速度,又有内存的大容量的存储系统了。缓存对CPU的性能影响很大,主要是因为CPU的数据交换顺序和CPU与缓存间的带宽引起的。
下图是一个典型的存储器层次结构,我们可以看到一共使用了三级缓存 :
CPU Cache分成了三个级别: L1, L2, L3. 级别越小越接近CPU, 所以速度也更快, 同时也代表着容量越小.
- L1是最接近CPU的, 它容量最小, 例如32K, 速度最快,每个核上都有一个L1 Cache
- L2 Cache 更大一些,例如256K, 速度要慢一些, 一般情况下每个核上都有一个独立的L2 Cache;
- L3 Cache是三级缓存中最大的一级,例如12MB,同时也是最慢的一级, 在同一个CPU插槽之间的核共享一个L3 Cache.
cpu与cache 内存交互的过程?
cpu与cache 内存交互的过程?CPU接收到指令后,它会最先向CPU中的一级缓存(L1 Cache)去寻找相关的数据,然一级缓存是与CPU同频运行的,但是由于容量较小,所以不可能每次都命中。这时CPU会继续向下一级的二级缓存(L2 Cache)寻找,同样的道理,当所需要的数据在二级缓存中也没有的话,会继续转向L3 Cache、内存(主存)和硬盘。
JMM内存模型(二)JMM 内存模型
介绍
Java内存模型(即Java Memory Model,简称JMM)。Java内存模型跟CPU缓存模型类似,是基于CPU缓存模型来建立的,Java线程内存模型是标准化的,屏蔽了底层不同计算机的区别。
JMM本身是一种抽象的概念,并不真实存在,它描述的是一组规则或规范,通过这组规范定义了程序中各个变量(包括实例字段,静态字段和构成数组对象的元素)的访问方式。由于JVM运行程序的实体是线程,而每个线程创建时JVM都会为其创建一个工作内存(有些地方称为栈空间),用于存储线程私有的数据。而Java内存模型中规定所有变量都存储在主内存,主内存是共享内存区域,所有线程都可以访问。
线程对变量的操作(读取赋值等)必须在工作内存中进行,首先要将变量从主内存拷贝的自己的工作内存空间,然后对变量进行操作,操作完成后再将变量写回主内存,不能直接操作主内存中的变量,工作内存中存储着主内存中的变量副本拷贝,前面说过,工作内存是每个线程的私有数据区域,因此不同的线程间无法访问对方的工作内存,线程间的通信(传值)必须通过主内存来完成,其简要访问过程如下图:
需要注意的是,JMM与Java内存区域的划分是不同的概念层次,更恰当说JMM描述的是一组规则,通过这组规则控制程序中各个变量在共享数据区域和私有数据区域的访问方式。
JMM是围绕原子性,有序性、可见性展开的。JMM与Java内存区域唯一相似点,都存在共享数据区域和私有数据区域,在JMM中主内存属于共享数据区域,从某个程度上讲应该包括了堆和方法区,而工作内存数据线程私有数据区域,从某个程度上讲则应该包括程序计数器、虚拟机栈以及本地方法栈。或许在某些地方,我们可能会看见主内存被描述为堆内存,工作内存被称为线程栈,实际上他们表达的都是同一个含义。关于JMM中的主内存和工作内存说明如下:
-
主内存
主要存储的是Java实例对象,所有线程创建的实例对象都存放在主内存中,不管该实例对象是成员变量还是方法中的本地变量(也称局部变量),当然也包括了共享的类信息、常量、静态变量。由于是共享数据区域,多条线程对同一个变量进行访问可能会发现线程安全问题。
-
工作内存
主要存储当前方法的所有本地变量信息(工作内存中存储着主内存中的变量副本拷贝),每个线程只能访问自己的工作内存,即线程中的本地变量对其它线程是不可见的,就算是两个线程执行的是同一段代码,它们也会各自在自己的工作内存中创建属于当前线程的本地变量,当然也包括了字节码行号指示器、相关Native方法的信息。注意由于工作内存是每个线程的私有数据,线程间无法相互访问工作内存,因此存储在工作内存的数据不存在线程安全问题。
演示
public class ThreadDemo {
private static boolean flag = false;
public static void main(String[] args) throws InterruptedException {
new Thread(()->{
long num =0;
while (!flag){
num++;
}
System.out.println("num = " + num);
}).start();
Thread.sleep(1);
new Thread(()->{
setStop();
}).start();
}
private static void setStop(){
flag = true;
}
}
JMM内存模型(三)CPU缓存与JMM内存模型的关系
通过对前面的CPU硬件内存架构、Java内存模型以及Java多线程的实现原理的了解,我们应该已经意识到,多线程的执行最终都会映射到硬件处理器上进行执行。
但Java内存模型和硬件内存架构并不完全一致。对于硬件内存来说只有寄存器、缓存内存、主内存的概念,并没有工作内存(线程私有数据区域)和主内存(堆内存)之分,也就是说Java内存模型对内存的划分对硬件内存并没有任何影响,因为JMM只是一种抽象的概念,是一组规则,并不实际存在,不管是工作内存的数据还是主内存的数据,对于计算机硬件来说都会存储在计算机主内存中,当然也有可能存储到CPU缓存或者寄存器中,因此总体上来说,Java内存模型和计算机硬件内存架构是一个相互交叉的关系,是一种抽象概念划分与真实物理硬件的交叉。
JMM内存模型与CPU硬件内存架构的关系:
2.4 并发编程三大特性
正因为有了JMM内存模型,以及java语言的设计,所以在并发编程当中我们可能会经常遇到下面几种问题。线程安全问题表现为三个方面:原子性、可见性和有序性
1. 原子性
原子性,即一个操作或多个操作,要么全部执行并且在执行的过程中不被打断,要么全部不执行。(提供了互斥访问,在同一时刻只有一个线程进行访问)
互斥锁:这种线程一旦得到锁,其他需要锁的线程就会阻塞等待锁的释放 (悲观锁)
CAS操作的就是乐观锁,每次不加锁而是假设没有冲突而去完成某项操作,如果因为冲突失败就重试,直到成功为止。
1、synchronized (互斥锁)
2、Lock(互斥锁)
3、原子类(CAS 乐观锁) 提供了许多原子性的类:AtomicInteger、AtomicIntegerArray等保证了线程安全
举个🌰:
如现实生活中从 ATM 机取款,对于用户来说,要么操作成功用户拿到钱,余额减少了,增加了一条交易记录;要么没拿到钱,相当于取款操作没有发生。
Java 有两种方式实现原子性:一种是使用锁;另一种利用处理器的 CAS(Compare and Swap)指令。
锁具有排它性,保证共享变量在某一时刻只能被一个线程访问
CAS 指令直接在硬件(处理器和内存)层次上实现,看作是硬件锁
2. 可见性
在多线程环境中,一个线程对某个共享变量进行更新之后,后续其他的线程可能无法立即读到这个更新的结果,这就是线程安全问题的另外一种形式:可见性(visibility)
如果一个线程对共享变量更新后,后续访问该变量的其他线程可以读到更新的结果,称这个线程对共享变量的更新对其他线程可见,否则称这个线程对共享变量的更新对其他线程不可见。
多线程程序因为可见性问题可能会导致其他线程读取到了旧数据(脏数据)a
当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值。
若两个线程在不同的cpu,那么线程1改变了i的值还没刷新到主存,线程2又使用了i,那么这个i值肯定还是之前的,线程1对变量的修改线程没看到这就是可见性问题。
// 演示可见性的案例
public class C2_线程安全_可见性案例 {
private static boolean flag = false;
public static void main(String[] args) throws InterruptedException {
new Thread(()->{
System.out.println("1号线程启动 执行while循环");
long num =0;
while (!flag){
num++;
}
System.out.println("num = " + num);
}).start();
Thread.sleep(1000);
new Thread(()->{
System.out.println("2号线程启动 更改变量 flag为true");
setStop();
}).start();
}
private static void setStop(){
flag = true;
}
}
已经将结果设置为fasle为什么?还一直在运行呢。
原因:线程之间是不可见的,读取的是副本,没有及时读取到主内存结果。
使用: volatile 关键字即可保证变量的可见性
private static volatile boolean flag = false; // 专用修饰变量,变量发生改变,会立刻被其他线程知道
3. 有序性
程序执行的顺序按照代码的先后顺序执行。
一般来说处理器为了提高程序运行效率,可能会对输入代码进行优化,它不保证程序中各个语句的执行先后顺序同代码中的顺序一致,但是它会保证程序最终执行结果和代码顺序执行的结果是一致的。如下:
int a = 10; //语句1
a = a + 3; //语句2
int r = 2; //语句3
r = a * a; //语句4
因为指令重排序(happen-before),他还可能执行顺序为 2-1-3-4,1-3-2-4,但绝不可能 2-1-4-3,因为这打破了依赖关系。
显然重排序对单线程运行是不会有任何问题,而多线程就不一定了,所以我们在多线程编程时就得考虑这个问题了。
3.1 重排序
在多核处理器的环境下,编写的顺序结构,这种操作执行的顺序可能是没有保障的:
编译器可能会改变两个操作的先后顺序;
处理器也可能不会按照目标代码的顺序执行;
这种一个处理器上执行的多个操作,在其他处理器来看它的顺序与目标代码指定的顺序可能不一样,这种现象称为重排序。
重排序是对内存访问有序操作的一种优化,可以在不影响单线程程序正确的情况下提升程序的性能。但是,可能对多线程程序的正确性产生影响,即可能导致线程安全问题。
重排序与可见性问题类似,不是必然出现的。
与内存操作顺序有关的几个概念:
源代码顺序,就是源码中指定的内存访问顺序。
程序顺序,处理器上运行的目标代码所指定的内存访问顺序。
执行顺序,内存访问操作在处理器上的实际执行顺序。
感知顺序,给定处理器所感知到的该处理器及其他处理器的内存访问操作的顺序
可以把重排序分为指令重排序与存储子系统重排序两种。
指令重排序主要是由 JIT 编译器引起的,指程序顺序与执行顺序不一样。
存储子系统重排序是由高速缓存,写缓冲器引起的,感知顺序与执行顺序不一致。
3.2 指令重排序
在源码顺序与程序顺序不一致,或者程序顺序与执行顺序不一致的情况下,我们就说发生了指令重排序。
指令重排是一种动作,确实对指令的顺序做了调整,javac 编译器一般不会执行指令重排序,而 JIT 编译器可能执行指
令重排序。
处理器也可能执行指令重排序,使得执行顺序与程序顺序不一致。
3.3 存储子系统重排序
存储子系统是指写缓冲器与高速缓存.
高速缓存(Cache)是 CPU 中为了匹配与主内存处理速度不匹配而设计的一个高速缓存,写缓冲器(Store buffer, Write buffer)用来提高写高速缓存操作的效率。即使处理器严格按照程序顺序执行两个内存访问操作,在存储子系统的作用下,其他处理器对这两个操作的感知顺序与程序顺序不一致,即这两个操作的顺序顺序看起来像是发生了变化,这种现象称为存储子系统重排序。
存储子系统重排序并没有真正的对指令执行顺序进行调整,而是造成一种指令执行顺序被调整的现象。存储子系统重排序对象是内存操作的结果,从处理器角度来看,读内存就是从指定的 RAM 地址中加载数据到寄存器,称为 Load 操作;写内存就是把数据存储到指定的地址表示的 RAM 存储单元中,称为 Store 操作,内存重排序有以下四种可能:
LoadLoad 重排序,一个处理器先后执行两个读操作 L1 和 L2,其他处理器对两个内存操作的感知顺序可能是 L2->L1
StoreStore重排序,一个处理器先后执行两个写操作 W1和 W2,其他处理器对两个内存操作的感知顺序可能是 W2->W1
LoadStore 重排序,一个处理器先执行读内存操作 L1 再执行写内存操作 W1,其他处理器对两个内存操作的感知顺序可能是 W1->L1
StoreLoad重排序,一个处理器先执行写内存操作W1再执行读内存操作 L1,其他处理器对两个内存操作的感知顺序可能是 L1->W1
内存重排序与具体的处理器微架构有关,不同架构的处理器所允许的内存重排序不同。内存重排序可能会导致线程安全问题。
3.4 貌似串行语义
JIT 编译器,存储子系统是按照一定的规则对指令内存操作的结果进行重排序,给单线程程序造成一种假象----指令是按照源码的顺序执行的,这种假象称为貌似串行语义。
并不能保证多线程环境程序的正确性
为了保证貌似串行语义,有数据依赖关系的语句不会被重排序,只有不存在数据依赖关系的语句才会被重排序。如果两个操作(指令)访问同一个变量,且其中一个操作(指令)为写操作,那么这两个操作之间就存在数据依赖关系。
如:
x = 1;
y = x + 1;
后一条语句的操作数包含前一条语句的执行结果;
y = x;
x = 1;
先读取 x 变量,再更新 x 变量的值;
x = 1;
x = 2;
两条语句同时对一个变量进行写操作,如果不存在数据依赖关系则可能重排序,如:
double price = 45.8;
int quantity = 10;
double sum = price * quantity;
存在控制依赖关系的语句允许重排,一条语句(指令)的执行结果会决定另一条语句(指令)能否被执行,这两条语句(指令)存在控制依赖关系(Control Dependency)。如在 if 语句中允许重排,可能存在处理器先执行 if 代码块,再判断 if 条件是否成立。
3.5 保证内存访问的顺序性
可以使用 volatile 关键字、synchronized 关键字实现有序性
本文来自博客园,作者:Lz_蚂蚱,转载请注明原文链接:https://www.cnblogs.com/leizia/p/15941902.html