文章目录
概念
在多线程并发执行
的情况下,出现了bug,就称为线程不安全,没有bug,就是线程安全
原因
随机调度
操作系统调度线程的顺序是随机
的
随机调度使⼀个程序在多线程环境下,执行顺序存在很多的变数.
抢占式执行
修改共享数据
java
public static int count=0;
由于线程共享同一个进程下的资源,此时的count是一个能够被多个进程访问到的"共享数据"
而多个线程修改同一个变量
会引发线程安全问题
例如
:创建两个线程,每个线程对count进行自增5000次
java
public class Demo {
private static int count=0;
public static void main(String[] args) throws InterruptedException {
ReentrantLock locker=new ReentrantLock();
Thread t1=new Thread(()->{
for (int i = 0; i < 5000; i++) {
count++;
}
});
Thread t2=new Thread(()->{
for (int i = 0; i < 5000; i++) {
count++;
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println("count:"+count);
}
}
结果和预期不一致,说明出现了线程安全问题
原因
:count++操作对应到3个CPU指令
load:把内存中count的值,加载到cpu的寄存器
add:把寄存器中的内容+1
save:把寄存器中的内容保存回内存中
由于随机调度
的根本性原因,可能执行了t1的load,之后就调度了t2中的load,执行着某个操作,执行着执行着可能就被其他线程调走了,count++操作就并没有真的做到count+1
理解并发和并行
并发:在同一个CPU上执行(两个线程有不同的上下文(一组自己的寄存器的值))
并行:在不同CPU上执行
修改操作不是原子的
原子性
概念:一个操作在执行的过程中是不可分割的,即该操作要么全部执行,要么全部不执行,不会在执行过程中被打断。
理解
:我们把⼀段代码想象成⼀个公共厕所,每个线程就是要进⼊这个厕所的人。如果没有任何机制保证,女生进入厕所之后,还没有出来;是不是男生也可以进入房间,打断女生在厕所里的隐私。这个就是不具备原子性的。
在java中,赋值操作是原子的,而++,--,+=,-=等操作就不一定是原子的
原因:它们实际上包含了多个步骤:读取变量i的值、将值加1(或减1)、然后将新值写回变量i。在这些步骤之间,其他线程可能会插入并执行自己的操作,从而干扰原始线程的操作结果。
内存可见性
概念:⼀个线程对共享变量值的修改
,能够及时地被其他线程看到
JMM ------ java内存模型
目的
:是屏蔽掉各种硬件和操作系统的内存访问差异,以实现让Java程序在各种平台下都能达到⼀致的并发效果.
• 线程之间的共享变量存在主内存
(Main Memory)------也就是我们平时说的内存
• 每⼀个线程都有自己的"工作内存"
(Working Memory)
• 当线程要读取⼀个共享变量的时候, 会先把变量从主内存拷贝
到工作内存,再从工作内存读
取数据.
• 当线程要修改⼀个共享变量的时候,也会先修改工作内存中的副本,再同步回主内存
.
把读内存操作优化成读寄存器操作,也是上面的操作
"主内存"才是真正硬件角度的"内存".
"工作内存",则是指CPU的寄存器和高速缓存.
寄存器虽然快,但是空间太小,存不了多少东西,所以大佬建设了一些存储空间,称为"缓存"
CPU的三级缓存
对于java程序员而言,内存数据缓存到CPU里,具体是在寄存器上,还是在L1,L2 ,L3上,不清楚,对java代码而言,无区别
由于每个线程有自己的工作内存,这些工作内存中的内容相当于同⼀个共享变量的"副本".此时修改线程1的工作内存中的值,线程2的工作内存不⼀定会及时变化.
指令重排序
理解指令重排序
在保证逻辑没有发生改变的情况下,编译器对代码进行优化
解决方案
针对多个线程修改同一个变量的解决方案------提供适当的同步机制
使用锁机制
引入java中的synchronzied
关键字
synchronized的锁特性
互斥性
synchronized会起到互斥效果,某个线程执行到某个对象的synchronized中时,其他线程如果也执行到同⼀个对象synchronized就会阻塞等待.
java
synchronized(locker){
//同步代码块
}
进入
到synchronized修饰的代码块
,相当于拿到锁,对当前对象进行"加锁"
退出
synchronized修饰的代码块
,相当于对当前对象"解锁"
而锁对象可以是任意
的
synchronized用的锁是存在Java对象头里的。
可见性
当一个线程访问被synchronized修饰的类或对象时,必须先获得它的锁,而这个锁的状态对于其他任何线程都是可见的
可重入性
如果一个线程已经持有某个对象锁,那么它可以再次获取这个对象锁而不会发生死锁
。
理解
:锁死
一个线程没有释放锁,然后又尝试再次加锁
假设第一次加锁成功
第二次加锁的时候锁已经被占用,此时阻塞等待
此时该线程若是一直拿着锁不释放,就会陷入死锁的状态
不可重入锁:当一个线程已经持有某个锁时,如果该线程再次尝试获取该锁,它将无法成功获取,即会被阻塞,直到持有锁的线程释放锁为止
而synchronized是可重入锁
,没有上述问题
写一个死锁(面试题)
两个线程两把锁,每个线程获取到一把锁之后,一个线程想获取另一个线程的锁,形成了竞争锁,会成阻塞状态blocked
,通过jconsle观察
java
public class Demo16 {
public static void main(String[] args) throws InterruptedException {
Object locker1=new Object();
Object locker2=new Object();
Thread t1=new Thread(()->{
synchronized (locker1) {
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
System.out.println("t1拿到了锁");
//不能释放第一把锁
synchronized (locker2){
System.out.println("t1尝试拿t2的锁");
}
}
});
Thread t2=new Thread(()->{
synchronized (locker2) {
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
System.out.println("t2拿到了锁");
synchronized (locker1){
System.out.println("t2尝试拿t1的锁");
}
}
});
t1.start();
t2.start();
t1.join();
t2.join();
}
}
构成死锁的条件
互斥 (不可打破)
资源是独占的,不能同时被多个进程共享
不可剥夺 (不可打破)
资源只能由持有它的进程主动释放,其他进程不能强制获取该资源
请求和等待(可打破------把嵌套的锁改成并列的锁)
一个进程在持有至少一个资源的同时,还在等待获取其他被其他进程持有的资源
循环等待 (可打破------加锁的顺序做出约定)
每个进程都在等待下一个进程释放资源,而下一个进程又在等待另一个进程,依此类推,导致所有进程都无法继续执行。
有序性
synchronized保证了每个时刻都只有一个线程访问同步代码块,确定了线程执行同步代码块是分先后顺序的
,从而保证了有序性
非公平锁
synchronized是非公平锁
,即不保证锁的获取顺序是按照线程请求的顺序进行的。这可能会带来一定的性能优势,因为线程可以在不等待其他线程释放锁的情况下尝试获取锁。
锁策略
悲观锁 vs 乐观锁
悲观:加锁的时候,预测接下来锁竞争的程度非常激烈,需要做额外的工作
乐观:加锁的时候,预测接下来锁竞争的程度不激烈,不需要做额外的工作
synchronized既是乐观锁又是悲观锁
------自适应
重量级锁 vs 轻量级锁
重量级锁,在悲观场景下,此时付出更多的代价---->更低效
轻量级锁,在乐观场景下,此时付出更小的代价---->更高效
挂起等待锁 vs 自旋锁
挂起等待锁------>重量级锁的典型实现---->操作系统内核级别的.加锁的时候发现竞争,就会使该线程进入阻塞状态,后续就需要内核唤醒(获取锁的周期更长,很难做到及时获取,但是省CPU,阻塞等待的过程中不消耗CPU
)
自旋锁------>轻量级锁的典型实现----->应用程序级别的,加锁的时候发现竞争,一般也不是进入阻塞,而是通过忙等的形式进行等待
(获取锁的周期短,及时获取锁,这个过程会一直消耗CPU
)
公平锁 vs 非公平锁
公平锁:先来先得,锁的获取顺序是按照线程请求的顺序进行的
非公平锁:概率均等,即不保证锁的获取顺序是按照线程请求的顺序进行的。
synchronized的锁机制
synchronized的锁机制包括偏向锁、轻量级锁和重量级锁
三种状态
无锁------>偏向锁:代码块进入synchronized代码块
偏向锁------>轻量级锁:拿到偏向锁的线程运行过程中,遇到了其他线程尝试竞争
这个锁(懒汉模式的思想体现------线程竞争不激烈)注意
是尝试申请锁,并不存在锁竞争
轻量级锁------>重量级锁:JVM发现当前竞争锁的情况非常激烈
,抢先拿到锁
synchronized的使用
synchronized必须要搭配⼀个具体的对象来使用
修饰代码块:明确指定锁哪个对象
锁任意对象
java
public class SynchronizedDemo {
private Object locker = new Object();
public void method() {
synchronized (locker) {
//代码块
}
}
}
锁当前对象
java
public class SynchronizedDemo {
public void method() {
synchronized (this) {
//要执行的代码块
}
}
}
修饰普通方法
锁的SynchronizedDemo对象
java
public class SynchronizedDemo {
public synchronized void methond() {
}
}
修饰静态方法
java
public class SynchronizedDemo {
public synchronized static void method(){
}
}
了解了上述的用法之后,我们来进行加锁,处理上述对于count++的线程安全问题
需要明确synchronized锁的是什么.只有两个线程竞争同⼀把锁,才会产生阻塞等待.
java
public class Demo13 {
private static int count=0;
public static void main(String[] args) throws InterruptedException {
//写一个对象作为锁对象
Object locker = new Object();
Thread t1=new Thread(()->{
for(int i=0;i<5000;i++){
synchronized(locker){
count++;
}
}
System.out.println("t1结束");
});
Thread t2=new Thread(()->{
for(int i=0;i<5000;i++){
synchronized(locker){
count++;
}
}
System.out.println("t2结束");
});
t1.start();
t2.start();
t1.join();
t2.join();
//t1t2谁先结束是不一定的,随机调度
System.out.println(count);
}
}
使用volatile关键字解决内存可见性问题
代码在写入volatile修饰的变量
的时候
• 改变线程工作内存中volatile变量副本的值
• 将改变后的副本的值从工作内存刷新到主内存
代码在读取volatile修饰的变量
的时候
• 从主内存中读取volatile变量的最新值到线程的工作内存中
• 从工作内存中读取volatile变量的副本
示例:
创建两个线程,t1包含一个循环,这个循环以flag==0为循环条件
t2从键盘中读取一个整数,并把这个整数赋值给flag
预期用户输入非0的数,t1线程结束
java
public class Demo17 {
//加入volatile关键字,这样的变量的读取操作,就不会被编译器优化了
private volatile static int flag=0;
public static void main(String[] args) {
Thread t1=new Thread(()->{
while (flag==0){
}
System.out.println("t1线程结束");
});
Thread t2=new Thread(()->{
Scanner sc=new Scanner(System.in);
System.out.println("请输入flag的值:");
flag=sc.nextInt();
});
t1.start();
t2.start();
}
}
volatile关键字不能保证原子性,但能保证内存可见性和禁止指令重排序
使用原子类解决原子性问题
java.util.concurrent.atomic 包中的 Atomic 原子类提供了一种线程安全的方式来操作单个变量。
基本类型原子类
使用原子的方式更新基本类型
AtomicInteger
:整型原子类
AtomicLong
:长整型原子类
AtomicBoolean:布尔型原子类
Atomic 类依赖于 CAS(Compare-And-Swap,比较并交换)乐观锁来保证其方法的原子性,而不需要使用传统的锁机制
上面三个类提供的方法几乎相同,所以我们这里以 AtomicInteger
为例子来介绍。
AtomicInteger类常用方法 :
方法 | 说明 |
---|---|
get() | 获取当前的值 |
getAndSet() | 获取当前的值,并设置新的值 |
getAndIncrement() | 获取当前的值,并自增 |
getAndDecrement() | 获取当前的值,并自减 |
getAndAdd | 获取当前的值,并加上预期的值 |
基于原子类解决线程安全问题
java
public class Demo32 {
public static void main(String[] args) throws InterruptedException {
AtomicInteger atomicInteger=new AtomicInteger();
Thread t1=new Thread(()->{
for(int i=0;i<5000;i++) {
atomicInteger.incrementAndGet();
}
});
Thread t2=new Thread(()->{
for(int i=0;i<5000;i++) {
atomicInteger.incrementAndGet();
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(atomicInteger.get());
}
}
wait和notify避免竞争关系
wait()
wait做的事情:
• 使当前执行代码的线程进行等待
.(把线程放到等待队列中)
• 释放当前的锁
• 满足⼀定条件时被唤醒,重新尝试获取这个锁
wait要搭配synchronized来使用.脱离synchronized使用wait会直接抛出异常.
wait结束等待的条件:
• 其他线程调用该对象的notify方法.
• wait等待时间超时(wait方法提供⼀个带有timeout参数的版本,来指定等待时间).
• 其他线程调用该等待线程的interrupted方法,导致wait抛出InterruptedException 异常.
notify()方法
notify方法是唤醒等待的线程
.
• 方法notify()也要在同步方法或同步块中调用
,该方法是用来通知那些可能等待该对象的对象锁的其
它线程,对其发出通知notify,并使它们重新获取该对象的对象锁。
• 如果有多个线程等待,则有线程调度器随机挑选出⼀个呈wait状态的线程
。(并没有"先来后到")
• 在notify()方法后,当前线程不会马上释放该对象锁,要等到执行notify()方法的线程将程序执行完,也就是退出同步代码块之后才会释放对象锁
。
notifyAll()方法
notify方法只是唤醒某⼀个等待线程.使用notifyAll方法可以⼀次唤醒所有的等待线程.
注意
:虽然是同时唤醒所有线程,但是这些线程需要竞争锁.所以并不是同时执行,仍然是有先有后的执行.
举例:有三个线程,线程名称分别为:a,b,c。
每个线程打印自己的名称。
需要让他们同时启动,并按 c,b,a的顺序打印
java
public class Demo18 {
public static void main(String[] args) throws InterruptedException {
Object locker1=new Object();
Object locker2=new Object();
Object locker3=new Object();
Thread t1=new Thread(()->{
try {
for (int i=0;i<10;i++){
synchronized (locker1) {
locker1.wait();
}
System.out.println(Thread.currentThread().getName());
synchronized (locker3){
locker3.notify();
}
}
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
},"a");
Thread t2=new Thread(()->{
try {
for (int i=0;i<10;i++){
synchronized (locker2) {
locker2.wait();
}
System.out.print(Thread.currentThread().getName());
synchronized (locker1){
locker1.notify();
}
}
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
},"b");
Thread t3=new Thread(()->{
try {
for (int i=0;i<10;i++){
synchronized (locker3) {
locker3.wait();
}
System.out.print(Thread.currentThread().getName());
synchronized (locker2){
locker2.notify();
}
}
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
},"c");
t1.start();
t2.start();
t3.start();
Thread.sleep(1000);//确保四个线程都陷入等待
synchronized (locker3){
locker3.notify();
}
}
}
注意事项
在同步块中使用
wait和notify方法必须在同步块synchronized中调用,因为它们涉及对对象锁的获取和释放
避免虚假唤醒
由于notify方法可能随机唤醒一个等待的线程,因此在使用时需要注意虚假唤醒的问题
确保正确的唤醒顺序
在使用notify或notifyAll方法时,需要确保唤醒的线程是按照预期的顺序和条件被唤醒的。否则,可能会导致程序逻辑错误或死锁等问题。
wait和sleep的区别
- wait是Object类的普通方法,sleep是Thread类的静态方法
- wait用于线程间的通信和协调,确保线程按照特定顺序执行,sleep用于暂停线程的执行一段时间,让出CPU资源给其他线程
- wait需要搭配synchronized使用,sleep不用