在看这篇之前,如果没有了解并发编程的基础,建议先看这篇文章
Java并发编程线程基础——小白入门篇:juejin.cn/post/696549…
一、什么是多线程并发编程
1. 并发与并行的概念
并发是指同一个时间段内多个任务同时都在执行,并且都没有执行结束,而并行是指在单位时间内多个任务同时在执行。
并发任务强调在一个时间段内同时执行,而一个时间段由多个单位时间累积而成,所以说并发的多个任务在单位时间内不一定同时执行。
下图所示为双CPU配置,线程A和线程B各自在自己的CPU上执行任务,实现了真正的并行运行。

而在多线程编程实践中,线程的个数往往多余CPU的个数,所以一般都称多线程并发编程而不是多线程并行编程。
二、为什么要进行多线程并发编程
多核 CPU 时代的到来打破了单核 CPU 对多线程效能的限制。多个CPU意味着每个线程可以使用自己的 CPU 运行,这减少了线程上下文切换的开销,但随着对应用系统性能和吞吐量要求的提高,出现了处理海量数据和请求的要求,这些都对高并发编程有着迫切的需求。
三、Java中的线程安全问题
1. 共享资源的概念
如果一个资源被多个线程所持有或者说多个线程都可以去访问该资源,那么这个资源就是共享资源。
2. 线程安全问题的概念
线程安全问题是指当多个线程同时去读写一个共享资源并且没有任何同步措施时,导致出现脏数据或者其他不可预见的结果的问题。
四、共享变量的内存可见性问题
在介绍内存可见性前,我们先来看看在多线程下处理共享变量时Java的内存模型,如下图所示
1. Java内存模型

Java 内存模型规定,将所有的变量都存放在主内存中,当线程使用变量时,会把主内存里面的变量复制到自己的工作空间或叫作工作内存,线程读写变量时操作的是自己工作内存中的变量。
Java内存模型是一个抽象的概念,在实际实现中线程的工作内存如下图所示:

图中所示是一个双核系统架构,每个核有自己的控制器和运算器,其中控制器包含一组寄存器和操作控制器,运算器执行算术逻辑运算。每个核都有自己的一级缓存,在有些架构里面还有一个所有CPU都共享的二级缓存。
Java内存模型里面的工作内存,就对应这里的L1或者L2缓存或者CPU的寄存器。
2. 内存不可见问题示例
当一个线程操作共享变量是,它首先从主内存复制共享内存到自己的工作内存,然后对工作内存里的变量进行处理,处理完后将遍历值共享到主内存。
那么假如线程A和线程B同时处理一个共享变量,会出现 么情况?我们使用上图所示的CPU架构,假设线程A和线程B使用不同 CPU 执行,并且当前两级Cache (L1和L2Cache)都为空,那么这时候由于Cache的存在,将会导致内存不可见问题,具体看下面的分析。
看懂下面的例子,就明白什么是共享变量的内存不可见问题了,所以请认真看
-
线程A首先获取共享变量X的值,由于两级Cache都没有命中,所以加载主内存中X的值,假如为0。然后把X=0的值缓存到两级缓存,线程A修改X的值为1,然后将其写入两级Cache并且刷新到主内存。线程A操作完毕后,线程A所在的 CPU 的两级Cache和主内存存里面的X值都是1。
-
线程B获取X的值,首先一级缓存没有命中,然后看二级缓存,二级缓存命中了所以返回X=1;到这里一切都是正常的,因为这时候主内存中也是X=1。然后线程B修改X值为2,并将其存放到线程B所在的一级Cache和共享二级Cache中,最后更新主内存X的值为2,到这里,一切都是好的。
-
线程A这次又需要修改X的值,获取时一级缓存命中,并且X=1,到这里问题就出现了,明明线程B已经把X的值修改为了2,为何线程A获取的还是1呢?这就是共享变量的内存不可见问题,也就是线程B写入的值对线程A不可见。
使用Java中的volatile关键字就可以解决这个问题。 看目录第六节
五、synchronized关键字
1. synchronized关键字介绍
synchronized块是Java提供的一种原子性内置锁,Java中的每个对象都可以把它当做一个同步锁来使用,这些Java内置的使用者看不到的锁被称为内部锁,也叫作监视器锁。
线程的执行代码再进入synchronized代码块前会自动获取内部锁,这时候其他线程访问该同步代码块是会被阻塞挂起。
拿到内部锁的线程在正常退出同步代码块或者抛出异常后或者同步块内调用了该内置锁资源的wait系列方法时释放该内置锁。内置锁是排他锁,也就是当一个线程获取这个锁后,其他线程必须等待该线程释放锁后才能获取该锁。
由于Java中的线程是与操作系统的原生线程一一对应的,所以当阻塞一个线程时,需要从用户态切换到内核态执行阻塞操作,这是很耗时的操作,而synchronized的使用就会导致上下文切换。
2. synchronized的内存语义
进入synchronized块的内存语义是把在synchronized块内使用到的变量从线程的工作内存中清除(注意,只是清除使用到的变量,未使用的不清除),这样在synchronized块内使用到该变量时就不会从线程的工作内存中获取,而是直接从主存中获取。
退出synchronized块的内存语义是把在synchronized块内对共享变量的修改刷新到主内存。
由此可见,synchronized可以解决共享变量内存可见性问题。
其实这也是加锁和释放锁的语义,当获取锁后会清空锁内本地内存中将会被用到的共享变量,在使用这些共享变量是从主内存进行加载,在释放锁是将本地内存中修改的共享变量刷新到主内存。
六、volatile关键字
1. 避免内存不可见问题
当一个变量被声明为volatile时,线程在写入变量时不会把值缓存在寄存器或者其他地方,而是会把值刷新会主内存。当其他线程读取该共享变量时,会从主内存重新获取最新值,而不是使用当前线程的工作内存中的值。
可以发现,volatile的内存语义和synchronized的内存语义有点相似,它同样可以解决内存可见性问题,相比于同步锁,volatile不会带来线程上下文的切换开销。
2. 避免指令重排序问题
Java内存模型允许编译器和处理器对指令重排序以提高运行性能,并且只会对不存在数据依赖性的指令重排序。
在单线程下重排序可以保证最终执行的结果与程序顺序执行的结果一致,但是在多线程下就会存在问题。
写volatile变量时,可以确保volatile写之前的操作不会被编译器重排序到volatile写之后。读volatile变量时,可以确保volatile读之后的操作不会被编译器重排序到volatile读之前。
七、什么是原子性操作
原子性操作是指在执行一系列操作时,这些操作要么全部执行,要么全部不执行,不存在只执行其中一部分的情况。
使用synchronized关键字可以实现线程安全性,即内存可见性和原子性,但是synchronized是独占锁,没有获取内部锁的线程会被阻塞掉,而这就需要上下文切换的开销,那有没有更好的实现呢?有,那就是CAS操作。
八、CAS操作
1. CAS介绍
CAS 即 Compare And Swap,是JDK提供的非阻塞原则性操作,它通过硬件保证了比较——更新操作的原子性。
JDK里面的Unsafe类提供了一系列的compareAndSwap* 方法,下面以compareAndSWapLong方法为例进行简单介绍:
- boolean compareAndSwapLong(Object obj,long valueOffset,long expect, long updat)方法:其中 compareAndSwap 的意思是比较并交换。 CAS 有四个操作数,分别为:对象内存位置、对象中的变量的偏移量、变量预期值和新的值。其操作含义是,如果对象 obj 中内存偏移量为 valueOffset 变量值为 epect ,则使用新的值update 替换旧的值 expect。这是处理器提供的一个原子性指令。
2. ABA问题介绍及解决方法
ABA问题具体如下:
假如线程I使用CAS修改初始值为A的变量X,那么线程I会首先去获取当前变量X的值(为A),然后使用CAS尝试修改X的值为B,如果使用CAS操作成功了,那么程序运行一定是正确的吗?
其实不一定,因为有可能在线程I获取变量X的值A后,在执行CAS前,线程II使用CAS修改了变量X的值为B,然后又使用CAS修改了变量X的值为A。所以虽然线程I执行CAS时X的值是A,但是这个A已经不是线程I获取时的A了。这就是ABA问题。
ABA 产生是因为变量的状态值产生环形转换,就是变量的值可以从A到B,然后再从B到A。如果变量的值只能朝着一个方向转换,比如A到B,B到C,不构成环形,就不会存在问题。
JDK中的AtomicStampedReference类给每个变量的状态值都配备了一个时间戳,从而避免了1ABA问题的产生。
九、伪共享
1. 什么是伪共享
当多个线程同时修改一个缓存行里面的多个变量时,由于同时只能有一个线程操作缓存行,所以相比将每个变量放到一个缓存行性能会有所下降,这就是伪共享。
那么什么是缓存行呢?
我们知道,为了解决计算机系统中主内存与CPU之间运行速度差问题,会在CPU与主内存之间添加一级或者多级高速缓存存储器(Cache)。这个Cache一般是被集成到CPU内部的,所以也叫CPU Cache。下图所示是两级Cache结构。

在Cache内部是按行存储的,其中每一行称为一个Cache行。
Cache行是Cache与主内存进行数据交换的单位,Cache行的大小一般为2的幂次数字节。

当CPU访问某个变量时,首先会去看CPU Cache内是否有该变量,如果有则直接从中获取,否则就去主内存里面获取该变量,然后把变量所在内存区域的一个Cache行大小的内存复制到Cache中。
由于存放到Cache行的是内存块而不是单个变量,所以可能会把多个变量存放到一个Cache行里。
2. 为何出现伪共享?
伪共享的产生是因为多个变量被放入了一个缓存行中,并且多个线程同时去写入缓存行中不同的变量。
为何多个变量会被放入一个缓存行呢?
因为缓存与内存交换数据的单位就是缓存行,当CPU要访问的变量没有在缓存中找到时,根据程序运行的局部性原理,会把该变量所在内存中大小为缓存行的内存放入缓存行。注意:地址连续的多个变量才有可能会被放到一个缓存行中。
下面看一段代码,一个二维数组,观察行遍历处理和列遍历处理在性能上的差异。
(1)行遍历数组
public class TestForContent {
static final int lineNum = 1024;
static final int columNum = 1024;
public static void main(String[] args){
long[][] array = new long[lineNum][columNum];
long startTime = System.currentTimeMillis();
for(int i=0; i<lineNum; i++){ //行
for(int j=0; j<columNum; j++){ //列
array[i][j] = i*2+j;
}
}
long endTime = System.currentTimeMillis();
long cacheTime = endTime - startTime;
System.out.println(cacheTime);
}
}
复制代码
运行结果

(2)列遍历数组
public class TestForContent1 {
static final int lineNum = 1024;
static final int columNum = 1024;
public static void main(String[] args){
long[][] array = new long[lineNum][columNum];
long startTime = System.currentTimeMillis();
for(int i=0; i<columNum; i++){ //列
for(int j=0; j<lineNum; j++){ //行
array[i][j] = i*2+j;
}
}
long endTime = System.currentTimeMillis();
long cacheTime = endTime - startTime;
System.out.println(cacheTime);
}
}
复制代码
运行结果

可以看到,列遍历处理数组花费的时间比行遍历处理要高,这是因为列遍历是跳跃式访问数组元素的,这破坏了程序访问的局部性原则,并且缓存是有容量控制的,当缓存满了时会根据一定的淘汰算法替换缓存行,这会导致从内存置换过来的缓存行的元素还没等到被读取就被替换掉了。
所以在单个线程下顺序修改一个缓存行中的多个变量,会充分利用程序运行的局部性原则,从而加速了程序的运行。而在多线程下并发修改一个缓存行中的多个变量是就会竞争缓存行,从而降低程序运行性能。
3. 如何避免伪共享?
从上面介绍我们可以知道,伪共享的根本原因是一个缓存行里存放了多个变量,当多个线程同时修改多个变量时,是不允许的。所以我们只要做到一个缓存行只放一个我们需要用到的变量就可以了。
public final static class FilledLong{
public volatile long value = 0L;
public long p1,p2,p3,p4,p5,p6;
}
复制代码
假设缓存行为64字节,那么我们在FilledLong类里面填充了6个long类型的变量,这样刚好就填充了一个缓存行,而我们只需要用到value变量,所以就避免了伪共享。
JDK8 提供了一个sun.misc.Contended注解,用来解决为共享问题。
十、锁的概述
1. 乐观锁与悲观锁
乐观锁和悲观锁是在数据库中引入的名词,但是在并发包锁里面也引入了类似的思想。
悲观锁指对数据被外界修改持保守态度,认为数据很容易就会被其他线程修改,所以在数据被处理前先对数据进行加锁,并在整个数据处理过程中,使数据处于锁定状态。
悲观锁的实现往往依靠数据库提供的锁机制,即在数据库中,在对数据记录操作前给记录加排它锁 如果获取锁失败, 则说明数据正在被线程修改,当前线程则等待或者抛出异常。如果获取锁成功,则对记录进行操作,然后提交事务后释放排它锁。
乐观锁相对悲观锁来说的,它认为数据在一般情况下不会造成冲突,所以在访问记录前不会加排它锁,而是在进行数据提交更新时,才会正式对数据冲突与否进行检测。
乐观锁并不会使用数据库提供的锁机制,一般在表中添加version字段或者使用业务状态来实现。乐观锁找到提交时才锁定,所以不会产生任何死锁。
2. 公平锁与非公平锁
根据线程获取锁的抢占机制,锁可以分为公平锁和非公平锁。 公平锁表示线程获取锁的顺序是按照线程请求锁的时间早晚来决定的,即先请求先得到锁;而非公平锁则在运行时闯入,即不一定先到先得。
ReentrantLock提供了公平和非公平锁的实现
- 公平锁:ReentrantLock pairLock = new ReentrantLock(true);
- 非公平锁:ReentrantLock pairLock = new ReentrantLock(false)。如果构造函数不传递参数,则默认是非公平锁。
在没有公平性需求的前提下结论使用非公平锁,因为公平锁会带来性能开销(线程状态切换)
3. 独占锁与共享锁
根据锁只能被单个线程持有还是能被多个线程共同持有,锁可以分为独占锁和共享锁。
独占锁保证任何时候都只有一个线程能得到锁,ReentrantLock 就是以独占方式实现的。共享锁则可以同时由多个线程持有,例如 ReadWriteLock 锁,它允许一个资源可以被多个线程同时进行读操作。
独占锁是一种悲观锁,由于每次访问资源都先加上互斥锁,这限制了并发性,因为读操作并不会影响数据的一致性 ,而独占锁只允许在同一时间由一个线程读取数据,其他线程必须等待当前线程释放锁才能进行读取。
共享锁则是一种乐观锁,它放宽了加锁的条件,允许多个线程同时进行读操作。
4. 什么是可重入锁?
当一个线程要获取一个被其他线程持有的独占锁时,该线程会被阻塞,那么当一个线程再次获取它自己己经获取的锁时是否会被阻塞呢?如果不被阻塞,那么我们说该锁是可重入的。即获取已经得到的锁时,不会被阻塞,则称该锁为可重入锁。
synchronized是可重入锁。
可重入锁的原理是在锁内部维护一个线程标示,用来标示该锁目前被那个线程占用,然后关联一个计数器。 一开始计数器值为0,说明该锁没有被任何线程占用。当一个线程获取了该锁时,计数器的值会变成1,这时其他线程再来获取该锁时,会发现锁的所有者不是自己而被阻塞。
但是当获取了该锁的线程再次获取锁时发现锁引用者是自己,就会把计数器 +1,当释放锁后计数器值 -1。当计数器值为0时,锁里面的线程标示被重置为null,这时候被则是的线程会被唤醒来竞争该锁。
5. 自旋锁
当前线程在获取锁时,如果锁已经被其他线程占用,它不马上阻塞自己,在不放弃CPU使用权的情况下,多次尝试获取(默认此时是10),很有可能在后面几次此时中其他线程已经释放了锁。 如果参数指定的次数后仍然没有获取到锁,则当前线程才会被阻塞挂起。
由于Java中的线程是与操作系统中的线程一一对应的,所以当一个线程在获取锁(比如独占锁)失败后,会被切换到内核状态而被挂起。当该线程获取到锁时有需要将其切换到内核状态而唤醒该线程。 从用户态切换到内核态的开销是比较大的,在一定程度上会影响并发性能。
自旋锁是使用CPU时间获取线程阻塞和调度的开销,但是还有可能这些CPU时间白白浪费。






















![[桜井宁宁]COS和泉纱雾超可爱写真福利集-一一网](https://www.proyy.com/skycj/data/images/2020-12-13/4d3cf227a85d7e79f5d6b4efb6bde3e8.jpg)

![[桜井宁宁] 爆乳奶牛少女cos写真-一一网](https://www.proyy.com/skycj/data/images/2020-12-13/d40483e126fcf567894e89c65eaca655.jpg)