并发编程
- 并行与并发
- 多线程
- 多线程优点
- 多线程带来的问题?
- JMM(Java内存模型)
- 硬件设备之间的速度差异带来的问题?
- JMM
- JVM 主内存与工作内存
- 并发编程核心问题--可见性,原子性,有序性
- 1.可见性
- 缓存不能及时刷新导致了可见性问题。
- 2.有序性
- 编译优化带来了有序性问题
- 3.原子性
- 线程切换带来的原子性问题
- 如何保证可见性和有序性?
- volatile关键字
- 使用volatile的场景
- 如何保证原子性?
- 加锁
- 原子变量
- 原子类
- 为什么会有原子类?
- 使用原子类AtomicInteger
- 总结:
- CAS
- CAS 的 ABA问题
- ABA 解决
- 锁的分类
- 乐观锁、悲观锁
- 独占锁、共享锁
- 公平锁、非公平锁
- 可重入锁
- 读写锁
- 分段锁
- 自旋锁
- synchronized几种锁状态
- AQS
- ReentrantLock锁实现
- synchronized锁实现
- JUC常用类
- ConcurrentHashMap
- ConcurrentHashMap如何保证线程安全?
- HashMap、HashTable、ConcurrentHashMap
- ConcurrentHashMap为什么不能存key和value为null?
- CopyOnWriteArrayList
- CopyOnWriteArraySet
- CountDownLatch
- CycliBarrier
并行与并发
- 单核 cpu 下,线程实际是串行执行的。操作系统中有一个组件叫做任务调度器,将 cpu 的时间片,分给不同的线程使用,只是由于 cpu 在线程间(时间片很短)的切换非常快,人类感觉是同时运行的。 总结为一句话就是:微观串行,宏观并行,一般会将这种线程轮流使用 cpu 的做法称为并发(concurrent)
- 多核 cpu 下,每个核(core)都可以调度运行线程,这时候线程可以是行的
- 如何理解并发?
- 大家排队在一个咖啡机上接咖啡,
交替执行,是并发
;两台咖啡机上面接咖啡, 是并行
- 从严格意义上来说,并行的多任务是真的同时执行,而对于并发来说,这个过程只是交替执行的,一会执行任务 A,一会执行任务 B,系统会不停地在两者之间切 换。
并发说的是在一个时间段内,多件事情在这个时间段内交替执行
。并行说的是多件事情在同一个时刻同事发生
。
多线程
Java 是最先支持多线程的开发的语言之一,Java 从一开始就支持了多线程 能力。由于现在的 CPU 已经多是多核处理器了,是可以同时执行多个线程的.
多线程优点
多线程技术使程序的响应速度更快 ,可以在进行其它工作的同时一直处于活动 状态,程序性能得到提升. 性能提升的本质 就是榨取硬件的剩余价值(硬件利用率).
多线程带来的问题?
- 安全性(访问共享变量)
- 性能(切换开销等)
JMM(Java内存模型)
硬件设备之间的速度差异带来的问题?
- 硬件的发展中,一直存在一个矛盾,CPU、内存、I/O 设备的速度差异。
- 速度排序:CPU > 内存 > I/O 设备
为了平衡这三者的速度差异,做了如下优化:
- CPU 增加了缓存,以均衡内存与 CPU 的速度差异;
- 操作系统以线程分时复用 CPU,进而均衡 I/O 设备与 CPU 的速度差异;
- 编译程序优化指令执行次序,使得缓存能够得到更加合理地利用。
JMM
Java 内存模型(Java Memory Model,JMM)规范了 Java 虚拟机与计算 机内存是如何协同工作的。Java 虚拟机是一个完整的计算机的一个模型,因此这个模型自然也包含一个内存模型——又称为 Java 内存模型。
注意 : 是java内存模型 不是 JVM模型
Java 内存模型,用于屏蔽掉各种硬件和操作系统的内存访问差异,以实现 让 Java 程序在各种平台下都能达到一致的并发效果,JMM 规范了 Java 虚拟机 与计算机内存是如何协同工作,规定了一个线程如何以及何时可以看到由其他线 程修改过后的共享变量的值,以及在必须时如何同步的访问共享变量
。
计算机在高速的 CPU 和相对低速的存储设备之间使用高速缓存,作为内存 和处理器之间的缓冲。将运算需要使用到的数据复制到缓存中,让运算能快速运 行,当运算结束后再从缓存同步回内存之中。
在多处理器的系统中(或者单处理器多核的系统),每个处理器内核都有自己 的高速缓存,它们有共享同一主内存(Main Memory)。
当多个处理器的运算任务都涉及同一块主内存区域时,将可能导致各自的缓存数据不一致。
JVM 主内存与工作内存
Java 内存模型的主要目标是定义程序中各个变量的访问规则,即在虚拟机中将变量(线程共享的变量)存储到内存和从内存中取出变量这样底层细节。
JMM中规定了所有的变量都存储在主内存中,每条线程还有自己的工作内存,线程对变量的所有操作都必须在工作内存中进行,而不能直接读写主内存中的变量。
这里的工作内存是 JMM 的一个抽象概念,也叫本地内存,其存储了该线程以 读 / 写共享变量的副本。
就像每个处理器内核拥有私有的高速缓存,JMM 中每个线程拥有私有的本地内存。
不同线程之间无法直接访问对方工作内存中的变量,线程间的通信一般有两种方式进行,一是通过消息传递,二是共享内存。Java 线程间的通信采用的是共享内存方式,线程、主内存和工作内存的交互关系如下图所示:
若将JVM内存区域与JMM主内存工作内存勉强对应起来,那从变量、主内存、工作内存的定义来看,主内存主要对应于 Java 堆中的对象实例数据部分,而工作内存则对应于虚拟机栈中的部分区域。
并发编程核心问题–可见性,原子性,有序性
1.可见性
缓存不能及时刷新导致了可见性问题。
一个线程对共享变量的修改,另外一个线程能够立刻看到,我们称为可见性。
对于如今的多核处理器,每颗 CPU 都有自己的缓存,而缓存仅仅对它所在的处理器可见,CPU 缓存与内存的数据不容易保证一致。
为了避免处理器停顿下来等待向内存写入数据而产生的延迟,处理器使用写缓冲区来临时保存向内存写入的数据。写缓冲区合并对同一内存地址的多次写, 并以批处理的方式刷新,也就是说写缓冲区不会即时将数据刷新到主内存中。
缓存不能及时刷新导致了可见性问题。
举例:
假设线程 1 和线程 2 同时开始执行,那么第一次都会将 a=0
读到各自的 CPU 缓存里,线程 1 执行 a++
之后 a=1
,但是此时线程 2 是看不到线程 1 中 a 的值的,所以线程 2 里 a=0
,执行 a++
后 a=1
。
线程 1 和线程 2 各自 CPU 缓存里的值都是 1,之后线程 1 和线程 2 都会将 自己缓存中的 a=1
写入内存,导致内存中 a=1
,而不是我们期望的 2
。
2.有序性
编译优化带来了有序性问题
有序性指的是程序按照代码的先后顺序执行。
编译器为了优化性能,有时候会改变程序中语句的先后顺序。
3.原子性
原子的意思代表着——“不可分”;
一个或多个操作在 CPU 执行的过程中不被中断的特性,我们称为原子性
原子性是拒绝多线程交叉操作的,不论是多核还是单核,具有原子性的量,同一 时刻只能有一个线程来对它进行操作.
CPU 能保证的原子操作是 CPU 指令级别的,而不是高级语言的操作符(+、-、*、/、++等)。线程切换导致了原子性问题
Java 并发程序都是基于多线程的,自然也会涉及到任务切换,任务切换的时机大多数是在时间片结束的时候。我们现在基本都使用高级语言编程,高级语言里一条语句往往需要多条 CPU 指令完成。
如 count++,至少需要三条 CPU 指令。
- 指令1:首先,需要把变量 count 从内存加载到工作内存;
- 指令 2:之后,在工作内存执行 +1 操作;
- 指令 3:最后,将结果写入内存;
线程切换带来的原子性问题
如上图,两个线程 A 和 B 同时执行 count++, 即便 count 使用 volatile 修饰,我们预期的结果值是 2,但实际可能是 1。
如何保证可见性和有序性?
volatile关键字
一个共享变量被volatile修饰后
- 一个线程对volatile变量修改后,结果对其他线程是立即可见的(保证了可见性)
- 禁止进行指令重排序(保证了有序性)
线程写入volatile变量值时 等效于 线程退出sync代码块(将写入工作内存的变量值同步到主内存)
线程读取volatile变量值时 等效于 线程进入sync代码块(先清空本地内存变量值,再从主内存获取最新值)
使用volatile的场景
- 修改变量值不依赖当前值时,如果依赖当前值,也就是类似
i++
这种操作
i++的操作可以分为三步,不是原子性的
- 获取:从主存中读取i的值
- 进行+1操作
- 将更新的值写入主存
- 读写变量时没有加锁
如何保证原子性?
保证原子性需要保证对共享变量的修改是互斥的
- 加锁是阻塞式实现
- 原子变量是非阻塞式实现
加锁
synchronized关键字
- 是独占锁/互斥锁,其他线程要获取锁只能等待
- 保证原子性.synchronized同步方法or代码块时,只能有一个线程可以执行该代码
- synchronized也可以保证可见性和有序性
原子变量
JUC中的atomic和locks包下的类,可以解决原子性问题
- java.util.concurrent.atomic
- java.util.concurrent.locks
原子类
原子类的原子性是通过 volatile + cas 实现原子操作的
如:AtomicInteger类中的value是volatile修饰的,保证了可见性和有序性,cas需要用到可见性
低并发下可以使用原子类
为什么会有原子类?
在单线程中,经常有类似i++
,++i
的操作,那么它是不是线程安全的?
写一段代码来测试
public class Atomic {
static int num=0;
public static void main(String[] args) {
for (int i = 0; i < 10; i++) {
new Thread(){
@Override
public void run() {
System.out.println(this.getName()+":"+(++num));
}
}.start();
}
}
}
循环10次,每次++,预期结果应该是10,计算的结果却是9,得出结论i++,++i
这种运算不是原子性的
使用原子类AtomicInteger
public class Atomic {
private static AtomicInteger atomicInteger = new AtomicInteger(0);
public static void main(String[] args) {
for (int i = 0; i < 10; i++) {
new Thread(){
@Override
public void run() {
System.out.println(this.getName()+":"+atomicInteger.incrementAndGet());
}
}.start();
}
}
}
使用了原子类,得到的结果是正确的,内部是线程安全的
总结:
- 缓存刷新不及时导致了可见性问题(volatile、synchronized、原子类可以解决)
- 编译优化,指令重排,导致了有序性问题(volatile、加锁可以解决)
- 线程切换导致原子性问题(加锁、原子变量)
CAS
Compare-And-Swap
比较并交换,硬件对并发的支持
自旋:不断的循环尝试,对cas这里极速不断尝试判断内存值是否已经被更新过
CAS 是一种乐观锁(不加锁),采用自旋的思想,轻量级锁。不会阻塞线程
- 内存值 V
第一次从内存中读到的值 - 预估值 A
(对读到的值进行操作,准备将操作结果写入前) 读到的内存值 - 更新值 B
操作后的值
比较第一次读入的值和写入前读入的值是否相等,相等就 V = B
不相等,就继续循环,不会阻塞线程
CAS 的 ABA问题
- 一个线程读到的内存值为A
- 在写入前,被其他线程将A修改为了B,又将B修改为了A
- 此时该线程要将结果写入内存值,第二次读入的值也是A,无法确定是否被其他线程修改过
public static void main(String[] args) throws InterruptedException {
AtomicInteger atomicInteger = new AtomicInteger(100);//默认值为100
new Thread(() -> {
System.out.println(atomicInteger.compareAndSet(100, 101));//设置预期值是100 修改值为101 true
System.out.println(atomicInteger.get());// 101
System.out.println(atomicInteger.compareAndSet(101, 100));;//设置预期值是101 修改值为100 true
System.out.println(atomicInteger.get());// 100
}).start();
Thread.sleep(1000);
new Thread(() -> {
System.out.println(atomicInteger.compareAndSet(100, 101));//返回true 说明修改成功 发生ABA问题 true
System.out.println(atomicInteger.get());// 101
}).start();
}
ABA 解决
给当前使用的类添加版本号
- 读入(A,1)
- 其他线程:(A,1)->(B,2); (B,2)->A(A,3)
- 写入前读入的(A,3)与内存中(A,1)不相同,不执行更新
public static void main(String[] args) throws InterruptedException {
AtomicStampedReference stampedReference = new AtomicStampedReference(100, 0);
new Thread(() -> {
try {
Thread.sleep(50);
} catch (InterruptedException e) {
e.printStackTrace();
}
stampedReference.compareAndSet(100,101,stampedReference.getStamp(),stampedReference.getStamp() + 1);
stampedReference.compareAndSet(101,100,stampedReference.getStamp(), stampedReference.getStamp() + 1);
}).start();
new Thread(() -> {
int stamp = stampedReference.getStamp();
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
boolean result = stampedReference.compareAndSet(100, 101, stamp, stamp + 1);
System.out.println(String.format(" >>> 修改 stampedReference :: %s ", result));
}).start();
}
锁的分类
乐观锁、悲观锁
乐观锁
- 对并发情况持乐观态度,不加锁。
- 采用CAS思想自旋,不断去尝试。
- java.util.concurrent.automic包下的原子类采用了CSA的自旋思想实现原子操作的更新
- 适合并发读取
悲观锁
- 对并发情况持悲观的态度,加锁
- 适合并发写入
独占锁、共享锁
独占锁
- 独占锁一次只能被一个线程获得
- synchronized、ReentrantLock、 ReentrantReadWriteLock.WriteLock 都是独占锁
共享锁
- 共享锁可以被多个线程获得,并发地访问共享资源
- ReentrantReadWriteLock.ReadLock 是共享锁
公平锁、非公平锁
公平锁
- 按请求锁的顺序来分配,ReentrantLock是公平锁实现,维护了一个队列,按请求锁的顺序排队
- 性能比非公平锁低
非公平锁
- 由线程自己去抢锁,不按请求顺序排队。
- synchronized是非公平锁
- ReentrantLock默认是非公平锁,可以通过AQS方式实现线程调度,变成公平锁
/**
* ReentrantLock默认非公平
*/
public ReentrantLock() {
sync = new NonfairSync();
}
/**
* 传入参数true 指定为 公平锁
* 传入参数false指定为 非公平锁
*/
public ReentrantLock(boolean fair) {
sync = fair ? new FairSync() : new NonfairSync();
}
可重入锁
- 可以重新进入的锁,避免了线程死锁
- 也叫“递归锁”,当一个线程进入外层方法获取锁时,方法内部调用了另一个同步方法,那么线程是可以进入的
- synchronized和ReentrantLock都是可重入锁
读写锁
读锁:多个线程可以获得锁
写锁:只能有一个线程获得锁(同一时间,只能有一个线程可以写操作)
- 写锁优先级大于读锁
- 写写互斥、读写互斥
分段锁
在每个分段上加锁,将锁的粒度细化,提高并发效率(ConcurrentHashMap)
自旋锁
不会放弃CPU时间片,通过自旋不断尝试去获取锁,不会阻塞线程,比较消耗CPU
加锁时间短的场景适合使用自旋锁
synchronized几种锁状态
- 无锁
- 偏向锁:只有一个线程访问,该线程自动获取锁
- 轻量级锁:锁状态为偏向锁时,又有线程访问,锁升级为轻量级锁,其他线程会自旋尝试获取锁(自旋到一定次数就阻塞了),不会阻塞线程,提升了效率
- 重量级锁:
锁状态为轻量级锁时,其他线程自旋到一定次数,线程阻塞,锁升级为重量级锁
获取不到锁的线程将阻塞,等待操作系统调度
AQS
在java.util.concurrent.locks
包下
AQS – AbstractQueuedSynchronizer
抽象同步队列
AQS是JUC中实现线程安全的一个核心组件
- 内部维护了一个锁状态:volatile 修饰(保证了可见性和有序性)的state,初始为0
- 提供了对state的原子操作方法,保证了原子性
- 维护了一个队列,保存等待获取锁的线程
- 维护了一些获取锁、添加线程到等待队列、释放锁的方法
多个线程来访问,如果有一个线程访问到了state,将state + 1
其他线程不能访问了,加到等待队列中
该线程结束后,state变回0,队列中下一个等待获取锁的线程才能进来
ReentrantLock锁实现
public class ReentrantLock implements Lock, java.io.Serializable
核心结构–3个内部类
//继承AQS的同步类
abstract static class Sync extends AbstractQueuedSynchronizer{}
//公平锁
static final class FairSync extends Sync {}
//非公平锁
static final class NonfairSync extends Sync {}
lock()方法
public void lock() {
//调用内部类Sync的lock()方法,
sync.lock();
}
synchronized锁实现
JUC常用类
ConcurrentHashMap
JDK5 增加 多线程并发安全的HashMap(锁分段/独占锁)
JDK8 修改为 CAS + synchronized
ConcurrentHashMap不像HashTable一样对整个put方法加锁,而是将每一个位置(Node)作为一个独立空间对其加锁,锁粒度变小,提高了并发访问效率
ConcurrentHashMap如何保证线程安全?
- 在进入put方法后,通过hash值计算添加的位置
- 若位置上没有元素,采用cas机制判断,添加元素
- 若位置上有元素了,使用链表第一个Node作为锁标记的对象(使用synchronized)
HashMap、HashTable、ConcurrentHashMap
- HashMap线程不安全,允许键和值为null
- HashTable线程安全,在put方法上加锁了,并发下只能有一个线程进入put,不允许键和值为null
- ConcurrentHashMap线程安全,不允许键和值为null
ConcurrentHashMap为什么不能存key和value为null?
- 不能存key为null:不能判断key为null 还是key没找到
- 不能存value为null:get()时不能判断是存入的value为空,还是map中没有改key-value的映射
public class ConcurrentHashMapDemo {
/*
HashMap是线程不安全的,不能并发操作的
ConcurrentModificationException 并发修改异常 遍历集合,并删除集合中的数据
Hashtable 是线程安全的 public synchronized V put(K key, V value)-->独占锁
锁直接加到了put方法上,锁粒度比较大,效率比较低
用在低并发情况下可以
Map<String,Integer> map = Collections.synchronizedMap(new HashMap<>());
ConcurrentHashMap
*/
public static void main(String[] args) {
Map<String, Integer> map = new ConcurrentHashMap<>();
//Map<String,Integer> map = new HashMap<>();
//Map<String,Integer> map = new Hashtable<>();
//模拟多个线程对其操作
for (int i = 0; i < 20; i++) {
new Thread(
() -> {
map.put(Thread.currentThread().getName(), new Random().nextInt());
System.out.println(map);
}
).start();
}
}
}
CopyOnWriteArrayList
ArrayList是线程不安全的;
Vector是线程安全的;但是给add()和get()都加了锁,一个线程写的时候or读的时候,其他线程不能去读,效率低下
CopyOnWriteArrayList只有写-写时,才会阻塞,可以并发读取(读操作没有锁)
可变操作 add()、set()等修改数据的操作,会先创建一个底层数组的副本,将要添加或修改的数据写入副本,用副本替换底层数组
/**
* ArrayList 并发情况下不能使用,不能同时有多个下次对其操作
* Vector 是线程安全的 对 读/取 方法加了锁
* 读和写用的同一把锁 <-- synchronized修饰非静态方法,默认同步对象是this
* CopyOnWriteArrayList
* 对 读 和 写 操作分离,读操作不用加锁,
* 写操作加锁,写时不影响读操作
* 多个线程同时添加时会互斥
* 添加时先将原来的数组复制一个副本,将数据添加到副本中,不影响读操作,最后再用副本替换原数组
*/
public class CopyOnWriteArrayListDemo {
public static void main(String[] args) {
//List<Integer> list = new ArrayList();//java.util.ConcurrentModificationException
List<Integer> list = Collections.synchronizedList(new ArrayList<Integer>());//转为线程安全的集合
//List<Integer> list = new Vector();
//List<Integer> list = new CopyOnWriteArrayList<>();
for (int i = 0; i < 10; i++) {//模拟10个线程对其操作
new Thread(
() -> {
list.add(new Random().nextInt());
System.out.println(list);
}
).start();
}
}
}
CopyOnWriteArraySet
底层用CopyOnWriteArrayList实现
在添加数据时,会控制不出现重复数据
/**
* CopyOnWriteArraySet
* 不允许重复数据出现的单列集合
* 底层是CopyOnWriteArrayList实现
* 添加时判断是否重复
*/
public class CopyOnWriteArraySetDemo {
public static void main(String[] args) {
Set<Integer> set = new CopyOnWriteArraySet<>();
for (int i = 0; i < 10; i++) {
new Thread(
() -> {
set.add(new Random().nextInt());
System.out.println(set);
}
).start();
}
}
}
CountDownLatch
- 减法计数辅助类,允许一个线程等待其他线程执行完后再执行
- 底层使用AQS实现
- new CountDownLatch(线程数量),指定的线程数量将会传给AQS中的state,每执行完一个线程,计数器-1–>AQS的state - 1
- state为0时,所有线程执行完了,关闭计数器,再去执行指定的线程
public class CountDownLatchDemo {
/**
* 辅助类
* 使一个线程 等待其他线程执行完毕后再执行
* 相当于一个程序计数器 是一个递减的计数器
* 先指定一个(线程)数量,当有一个线程执行结束后,就减一 ,直到减为0 关闭计数器
* 此时线程就可以执行了
*/
public static void main(String[] args) throws InterruptedException {
CountDownLatch countDownLatch = new CountDownLatch(10);
for (int i = 0; i < 10; i++) {
new Thread(
() -> {
System.out.println(Thread.currentThread().getName());
countDownLatch.countDown();//计数器减一操作
}
).start();
}
countDownLatch.await();//关闭计数器
System.out.println("main线程");
}
}
如果不使用辅助类CountDownLatch,线程将是随机执行的
CycliBarrier
- 加法计数辅助类,让一组线程都到达某一屏障时,必须等最后一个线程到达屏障,才开始执行
public class CyclicBarrierDemo {
/*
CyclicBarrier 让一组线程到达一个屏障时被阻塞,直到最 后一个线程到达屏障时,屏障才会开门
是一个加法计数器,当线程数量到达指定数量时,开门放行
*/
public static void main(String[] args) {
CyclicBarrier c = new CyclicBarrier(5, () -> {
System.out.println("大家都到齐了 该我执行了");
});
for (int i = 0; i < 5; i++) {
new Thread(
() -> {
System.out.println(Thread.currentThread().getName());
try {
c.await();//加一计数器
} catch (InterruptedException e) {
e.printStackTrace();
} catch (BrokenBarrierException e) {
e.printStackTrace();
}
}
).start();
}
}
}