JUC并发编程知识二(待完善)
多线程
Java并发常见面试题总结(上) | JavaGuide
1.什么是多线程?
多线程是指一个进程中创建了多个线程,多个线程共享进程资源,并且各自执行独立的任务。
2.多线程的好处
多线程能够提高CPU的利用率,当一个线程进入阻塞状态时,其他线程能够可以继续使用CPU,避免CPU闲置。
- CPU是计算机的核心硬件,购买和运行是存在成本的,让CPU闲置意味着 “付费却不用”,提高利用率才能够对得起CPU的高成本
3.多线程带来的问题
并发编程并不总是能提高程序运行速度的,而且并发编程可能会遇到很多问题,比如:内存泄漏、死锁、线程不安全等等
并发、并行
1.什么是并发、并行?
并发:一个处理器处理多个任务,多个任务交替执行。
并行:多个处理器同时处理多个不同任务
2.并发的三大特性
原子性:一个操作或一系列操作过程要么全部执行成功,要么全部执行失败。期间不会被其他线程中断。
可见性 :一个线程修改一个共享变量,其他线程能够立即看到
有序性:程序执行顺序和代码先后顺序一致
如何理解线程安全?
线程不安全是指在多线程环境下,多个线程对同一份数据进行访问或操作时,会出现数据不一致问题。例如:两个线程进行i++操作,i++分为三个步骤:读取、计算、写入。由于i++并不是原子性,两个线程同时进行i++是会发生1+1<2 情况。
public class TicketTest { //多个线程共享的资源,售票数 private int ticketCount =10; //售票方法 public void ticket(){ if (ticketCount>0){ System.out.println(Thread.currentThread().getName()+ "卖出一张表." + "剩余票数:" + ticketCount-- ); try { Thread.sleep(3000); } catch (InterruptedException e) { throw new RuntimeException(e); } } else { System.out.println("票数已售馨"); } } public static void main(String[] args) { TicketTest test = new TicketTest(); Thread thread1 = new Thread(new Runnable() { @Override public void run() { for (int i = 0; i < 5; i++) { test.ticket(); } } },"窗口A"); Thread thread2 = new Thread(new Runnable() { @Override public void run() { for (int i = 0; i < 5; i++) { test.ticket(); } } },"窗口B"); Thread thread3 = new Thread(new Runnable() { @Override public void run() { for (int i = 0; i < 5; i++) { test.ticket(); } } },"窗口C"); thread1.start(); thread2.start(); thread3.start(); } }1.线程不安全的原因
线程不安全的核心原因:多线程访问共享资源时,因为原子性、有序性、可见性被破坏,导致代码逻辑混乱,数据出现不一致。
2.原子性问题
1.什么是原子性问题?
原子性问题是在多线程环境下,一个线程在执行一个操作过程中被其他线程打断,导致数据出现错误。
2.产生原子性问题的原因
CPU 时间片随机切换:多线程环境下,CPU会频繁切换不同的线程,导致一个线程的操作被强行中断。
执行的操作本身不是原子性:如:i++操作分为“读值→修改→写回”3 条 CPU 指令,由于这些指令不是原子的(无法一次性执行完毕),一旦在指令间隙发生 CPU 切换,其他线程就可能读取到 “中间状态” 的值,或修改未完成的结果,最终导致数据错误。
3.如何保证原子性?
可以通过是使用synchronized关键字、lock接口、原子类(如:AtomicInteger类)
synchronized:只有一个线程能够获取锁进入代码块中,其他线程若未获取到锁,会阻塞等待,避免操作被打断。
lock接口:提供了比synchronized更灵活的锁机制(如可中断、超时获取、公平锁等),通过lock()获取锁、unlock()释放锁,保证操作原子性。
原子类(无锁机制,高效):基于CAS机制实现无锁原子操作,直接操作内存,比较内存中的值与预期值,如果一致则更新为新值,否则重试。CAS 不会阻止线程中断,但能处理中断后的冲突
3.可见性问题
1.什么是可见性问题?
可见性问题是指一个线程对共享变量进行修改时,其他线程无法立即看到
2.产生可见性问题原因
1. JMM 规定所有共享变量存放于主内存,每个线程都有自己工作内存,线程仅能操作工作内存中的变量副本,无法直接读写主内存。一个线程在修改变量副本后,不会实时同步到主内存,其他线程加载的仍是旧副本。
2. CPU 多级缓存架构,不同核心缓存无法即时同步,是该问题的物理基础。
3.如何保证可见性?
volatile
、synchronized、lock、原子类可以保证可见性
volatile
当线程修改volatile变量时,先在工作内存中进行修改,然后立即通过内存屏障将新值强制写入到主内存中。
当线程读取volatile变量时,先在工作内存中将变量副本标记为“失效”,然后从主内存加载最新值到工作内存,最后读取本地内存中的新值。
synchronized
- 释放锁时同步:线程释放锁前,会将工作内存中修改的变量副本同步到主内存。
- 获取锁时刷新:线程获取锁后,会清空工作内存中的变量副本,从主内存重新加载最新值。
原子类:底层基于 CAS 操作实现,CAS 操作是直接与主内存交互,每次读写都针对主内存中的变量值,因此天然保证可见性。
4.有序性问题
1.什么是有序性问题?
有序性问题是指多线程环境下,因代码指令执行顺序与代码书写顺序不一致,导致的数据错误。
- “代码指令” 本质是CPU 可执行的最小操作单元—— 我们编写的高级语言代码(如 Java、C++),最终会被编译器 / 解释器翻译成 CPU 能直接执行的底层指令(机器指令)
2.产生有序性问题原因?
编译器和处理器为了优化性能,在不改变单线程的执行结果下,会对代码指令进行重新排序。但在多线程环境下,线程间不知道对方是否进行了重排序,导致执行顺序与代码书写顺序不一致,从而出现数据错误。
高级语言代码(如Java)→ 编译器翻译 → 汇编指令 → CPU优化(可能重排)→ 机器指令(最终执行)// 共享变量 volatile int x = 0; volatile boolean flag = false; // 线程A void taskA() { x = 1; // 操作1 flag = true;// 操作2,与x无数据依赖,允许重排序 } // 线程B void taskB() { if (flag) { System.out.println(x); } }x=1和flag=true编译器 / CPU 会交换顺序;- 可能重排执行顺序:先
flag=true,再x=1; - 多线程异常场景:线程 B 读到
flag=true时,x 还未赋值,打印结果为 0,违背预期。
3.如何保证有序性?
使用volatile
、synchronized实现有序性。
volatile:通过内存屏障禁止指令重排序
synchronized:
- synchronized 具备互斥性:同一时间只有一个线程执行同步块;
- 必须完整执行完同步块所有代码才会释放锁。 就算块内指令交换顺序,线程 B 必须等 A 完全退出同步块、全部变量刷新到主存后,才能进入同步块读取。
int x = 0; boolean flag = false; // 线程A void taskA() { synchronized (this) { // 加锁,插入Load屏障 x = 1; flag = true; } // 释放锁,插入Store屏障 } // 线程B void taskB() { synchronized (this) { if (flag) { System.out.println(x); } } }synchronized
1.什么是synchronized关键字?
synchronized是Java中的一个关键字,也叫同步锁。被它修饰的方法或者代码块,在多线程环境下,同一时间只有一个线程能够获取锁进入代码块执行,其他线程会被阻塞。
2.synchronized如何使用?
- 加在代码块上时,锁是的括号的里的对象,只有持有该对象锁的线程才能执行同步代码块。
- 加在普通方法上时,锁的是当前对象,只有一个线程可以执行该对象的同步方法,不同对象可以同时获取到各自的锁
- 加在静态方法上时,锁的是当前类对象,所有对象共享一个类锁 ,只有一个线程可以执行该类的静态同步方法。
1️⃣ 修饰代码块(同步代码块) public class SynchronizedBlockExample { private final Object lock = new Object(); public void method() { // 非同步代码 System.out.println("Non-synchronized code"); // 同步代码块 synchronized (lock) { System.out.println("Synchronized code block"); } } } 2️⃣ 修饰普通方法(同步方法) public synchronized void methodA() { // 同一对象的不同线程互斥执行 } 3️⃣ 修饰静态方法(类锁) public static synchronized void methodB() { // 所有对象共享一把锁 }public class SimpleSyncBlock { // 锁对象 private Object lock = new Object(); private int num = 0; public void add() { // 非同步部分:多个线程可同时执行 System.out.println(Thread.currentThread().getName() + " 准备加锁"); // 同步代码块:需要获取lock锁才能进入 synchronized (lock) { num++; System.out.println(Thread.currentThread().getName() + " 持有锁,num=" + num); try { Thread.sleep(500); // 模拟操作耗时 } catch (InterruptedException e) { e.printStackTrace(); } } // 自动释放锁 System.out.println(Thread.currentThread().getName() + " 释放锁\n"); } public static void main(String[] args) { SimpleSyncBlock demo = new SimpleSyncBlock(); // 3个线程竞争同一把锁 new Thread(demo::add, "线程1").start(); new Thread(demo::add, "线程2").start(); new Thread(demo::add, "线程3").start(); } }3.synchronized是如何实现的?
需要先了解在 JVM 中,Java对象保存在堆中时,由以下三部分组成:
1、对象头
对象头由两个部分组成
Mark Word(标记字段):根据锁状态保存不同的锁信息
Klass Point(类型指针):告诉 JVM创建的对象是啥类型的
2、实例数据
3、对齐填充
synchonized基于JVM的Monitor (监视器锁)和对象头实现的。
4.synchonized锁升级过程
无锁状态:对象刚创建,没有任何线程竞争,此时为无锁状态。
偏向锁:当第一个线程第一次获取锁时,锁升级为偏向锁。 JVM 会在对象头的 Mark Word 里记录该线程 ID。 之后这个线程再次获取锁时,不需要 CAS,而是直接进入业务逻辑。
轻量级锁:当有其他线程尝试竞争时,偏向锁会升级为轻量级锁。每个线程会在自己栈帧中创建锁记录,对象头的 Mark Word用来存储锁记录指针。其他线程通过CAS修改 Mark Word的方式获取锁,修改成功则加锁,失败的线程自旋重试。
重量级锁:当锁竞争激烈,线程自旋多次仍无法获取锁时,轻量级锁升级为重量级锁。JVM 会为对象关联 Monitor 监视器,然后修改对象头 Mark Word 用来存储监视器指针。
线程获取锁的方式是通过抢占监视器中的互斥量,抢不到锁的线程放入到阻塞队列中,直到持有锁的线程释放锁时,操作系统会唤醒阻塞队列中线程来重新竞争锁。
JUC常见的工具类
1.lock锁
1.什么是lock接口?
Lock是JUC.lock包下的接口,他比synchronized更加灵活,用来代替synchronized。与synchronized不同的是,需要手动加锁、解锁。
Lock lock = new ReentrantLock(); try { lock.lock(); 手动加锁 操作共享资源 } finally { lock.unlock(); 手动解锁(必须在 finally 中,确保执行) }2. lock和synchronized的区别
- lock是Java中的一个接口;synchronized是Java中的关键字
- lock只给代码块加锁;synchronized可以给代码块、方法
- lock是显示锁,需要手动加锁、解锁;synchronized是隐式锁,加锁、解锁有JVM自动完成
2.lock锁常用实现类
ReentrantLock (可重入锁)
1、ReentrantLock是什么?
同一个线程可以重复多次获取同一把锁,每获取一次锁,锁计数 + 1; 释放时必须调用unlock()将锁计数进行归0,当计数归 0 才算真正释放锁。
2、ReentrantLock 锁具备以下特性:
可中断:除了lock()方式加锁,还有可中断加锁:lockInterruptibly()。如果线程获取锁失败,
线程进入阻塞队列等待,等待期间是可以被其他线程中断,通过Thread.interrupt()中断
ReentrantLock lock = new ReentrantLock(); try { lock.lockInterruptibly(); // 可被中断的加锁 try { // 临界区代码 } finally { lock.unlock(); } } catch (InterruptedException e) { // 处理中断(例如恢复中断状态、记录日志等) Thread.currentThread().interrupt(); // 可选:恢复中断标记 }可以设置超时时间:允许线程在指定时间内获取锁,若在指定时间内获取锁,则返回true,若超过指定时间未获取锁,则返回false,不再继续等待锁释放。通过tryLock(long timeout, TimeUnit unit)方法设置超时时间。
可以设置为公平锁和非公平锁:
- 公平锁:锁释放后,按线程等待的时间顺序来获取锁,也就是先到先得。
- 非公平锁:锁释放后,线程可以插队获取锁,随机或者按照其他优先级排序。
ReentrantLock fairLock = new ReentrantLock(true); 公平锁 ReentrantLock nonFairLock1 = new ReentrantLock(); 默认非公平锁 ReentrantLock nonFairLock2 = new ReentrantLock(false); 显式指定非公平锁支持多个条件变量
支持通过Condition接口创建多个条件变量(条件队列)。通过newCondition()方法创建多个独立的Condition对象,每个Condition对应一个单独的条件队列,实现线程按不同条件等待 / 唤醒,精细化控制线程协作
private final ReentrantLock lock = new ReentrantLock(); 两个条件变量:队列满(生产者等待)、队列空(消费者等待) private final Condition notFull = lock.newCondition(); private final Condition notEmpty = lock.newCondition();3、ReentrantLock实现原理
(ReentrantLock内部类Sync继承了AQS实现了加锁、公平锁、非公平锁)
- ReentrantLock是基于AQS实现的可重入锁
- ReentrantLock用AQS的
state变量表示重入次数;用 AQS 的CLH队列管理竞争锁失败的线程。 - ReentrantLock两种加锁方式:
- 非公平锁加锁,线程先通过CAS尝试将state变量值从0修改为1,如果修改成功则获取锁,修改失败线程封装成一个Node节点放入CLH 队列中。
- 公平锁加锁,先检查 CLH队列是否有等待线程,如果有则将当前线程封装为 Node节点放入队列中,如果没有则尝试通过CAS修改state变量值为1,成功则线程持有锁,失败再放入CLH队列。
4. 释放锁是调用unlock(),将state的值进行递减,递减为0时,锁释放。
4、与synchronized有什么区别?
用法不同
- synchronized 是关键字,可直接用来修饰普通方法、静态方法和代码块;
- ReentrantLock是一个类, 只能用在代码块里
获取锁和释放锁方式不同
- synchronized 会自动加锁和释放锁
- ReentrantLock 需要手动加锁和释放锁
锁类型不同
- synchronized 属于非公平锁
- ReentrantLock 既可以是公平锁也可以是非公平锁
底层实现不同
- synchronized 是通过监视器和对象头实现的
- ReentrantLock 是基于 AQS 实现的
5、与synchronized有什么性能差异?
JDK1.6 之前:synchronized 只有重量级锁,当线程阻塞时会切换到内核态,然后将线程放入操作系统内核阻塞队列,线程唤醒时又要切回用户态,两次上下文切换性能开销大; ReentrantLock 基于 AQS、CAS 自旋实现的,是用户态操作,所以性能远高于synchronized。
JDK1.6 及之后:synchronized 进行了优化,新增了偏向锁和轻量级锁。在低竞争或单线程场景中,synchronized 的偏向锁和轻量级锁性能接近甚至高于 ReentrantLock 。但在高竞争环境下,ReentrantLock 通常性能更高,因为他的非公平锁机制和自旋等待减少了线程阻塞的开销。
ReentrantReadWriteLock (可重入读写锁)
ReentrantReadWriteLock是一个可重入的读写锁,他实现了ReadWriteLock接口,将锁分为读锁(共享锁)和写锁(排他锁)。允许多个线程同时读取共享资源,但修改资源时,只允许一个线程能够获取到写锁。
3.atomic原子类
1.atomic原子类是什么?
atomic原子类简单来说就是具有原子性操作特征的类。atomic原子类提供了一种线程安全的方式来操作单个变量。
2.常见的atomic原子类分类
基本数据类型原子类
AtomicInteger:整型原子类AtomicLong:长整型原子类AtomicBoolean:布尔型原子类
数组类型原子类
AtomicIntegerArray:整形数组原子类AtomicLongArray:长整形数组原子类AtomicReferenceArray:引用类型数组原子类
引用类型原子类
AtomicReference:引用类型原子类AtomicStampedReference:原子更新带有版本号的引用类型。
3.atomic类底层实现原理
原子类的实现依赖两大机制:
CAS:使用
Unsafe类的原子指令实现变量的比较并更新volatile:确保内存可见性,防止线程缓存变量造成数据不同步
java内存模型
1.前置知识
计算机在执行程序时,每条指令都是在CPU中执行的。而执行指令的过程中,势必涉及到数据的读取和写入。由于程序运行过程中的临时数据是存放在主存(物理内存)当中的,这时就存在一个问题,由于CPU执行速度很快,而从内存读取数据和向内存写入数据的过程,跟CPU执行指令的速度比起来要慢的多(硬盘 < 内存 <缓存cache < CPU)。因此如果任何时候对数据的操作都要通过和内存的交互来进行,会大大降低指令执行的速度。因此在CPU里面就有了高速缓存。CPU进行计算时,就可以直接从它的高速缓存中读取数据或向其写入数据了。当运算结束之后,再将高速缓存中的数据刷新到主存当中。
该知识点解释了 CPU 高速缓存存在的原因,这也是理解 JMM(Java 内存模型)和多线程可见性问题的底层基础。
2.JMM核心概念
JMM 规定共享变量存于主内存,每个线程拥有独立的工作内存。线程不能直接读写主内存,必须把共享变量拷贝到工作内存,所有操作(读、改、赋值)都在工作内存中完成,操作完后,再决定何时同步回主内存。
volatile关键字
1.volatile关键字的作用
volatile的作用是保证变量的可见性和禁止指令重排。
1、保证变量的可见性
当一个共享变量被volatile修饰,一个线程对共享变量进行修改,其他线程会立即看修改后的值。
2、禁止指令重排
指令重排是指编译器和处理器为了优化性能,对代码指令的执行顺序进行重新排序,volatile通过内存屏障限制 “特定范围内” 的指令进行重新排序,保证代码指令顺序与编写代码顺序一致,避免因为代码指令重新排序导致数据错误。
2.如何保证变量的可见性?
当线程修改volatile变量时,先在工作内存中进行修改,然后立即通过内存屏障将新值强制写入到主内存中。
当线程读取volatile变量时,先在工作内存中将变量副本标记为“失效”,然后从主内存重新加载最新值到工作内存, 最后读取最新值。
3.如何禁止指令重排序?
JMM 实际定义了 4 种内存屏障:LoadLoad、StoreStore、LoadStore、StoreLoad。
- 在 volatile 写操作之前插入StoreStore屏障:防止前面的普通写被重排序到 volatile 写之后。
- 在 volatile 写操作之后插入StoreLoad屏障:防止 volatile 写与后面可能出现的 volatile 读/写发生重排序,这是开销最大的屏障。
- 在 volatile 读操作之后插入LoadLoad屏障:防止 volatile 读与后面的普通读发生重排序。
- 在 volatile 读操作之后插入LoadStore屏障:防止 volatile 读与后面的普通写发生重排序。
// 声明变量加volatile volatile boolean flag; volatile int num; // volatile 写 a=1 【StoreStore屏障】 flag=true // volatile写 【StoreLoad屏障】 后续代码4.volatile和 synchronized的区别
- volatile是修饰变量;synchronized是修饰代码块、方法
- volatile能保证变量的可见性,但不能保证原子性,synchronized都可以保证
- volatile不会造成线程阻塞,synchronized会造成线程阻塞
CAS
1.CAS是什么?
CAS(compareAndSetState)全称为比较并交换,CAS 是一种乐观锁机制,它包含三个操作数:内存位置(V)、预期值(A)和新值(B)。CAS 操作的逻辑是,如果内存位置 V 的值等于预期值 A,则将其更新为新值 B,否则不做任何操作。而且整个过程是原子性的。
2.CAS工作原理
- 线程修改共享变量时,先从主内存读取变量,存入工作内存作为预期值(旧值)
- 比较预期值是否与主内存中的实际值相等
- 如果相等,说明变量没有被其他线程修改过,将实际值更新为准备的新值
- 如果不相等,说明变量被其他线程修改过,线程修改执行失败。
- 失败后,会进行自旋,重复执行相同操作,直到成功
例如:线程A要修改一个变量值X,X值为5修改为10。会先读取内存的5作为预期值。线程B此时把X值修改为8,8为X的实际值。线程A开始执行比较,比较后预期值与实际值不相等,线程A执行失败。
3.CAS是如何实现的?
CAS是基于一个Unsafe类实现,Unsafe的方法被native关键字修饰,通过“JNI”调用Unsafe类中的方法,最终映射到硬件级别的原子指令,从而保证操作的原子性。
(native关键字修饰,意味着这些方法的具体实现不是用 Java 代码写的,而是由底层的本地代码实现,通常是C++或者C。使用Unsafe类相当于直接对底层进行操作,不推荐直接使用)
4.CAS存在的问题
1. ABA问题
ABA问题是指变量A修改为变量B再修改为变量A,这过程CAS无法检测
如何解决ABA问题
- 解决思路:在变量修改前加是版本号或者时间戳。当一个线程修改变量前,先读取版本号的值,比较版本号值是否与当前版本号的值是否相等,如果相等则修改成功,如果不想相等个则回滚当前操作。
2. 自旋开销大
多个线程竞争同一个共享变量时,大量线程会因 CAS 失败而进行自旋,一直重复之前的操作,导致CPU资源被占用。
如何解决自旋开销大问题
- 限制自旋次数:设定最大自旋次数(如 10 次),超过次数后放弃自旋
3.只能保证一个变量的原子操作
CAS 操作仅能对单个共享变量有效。当需要操作多个共享变量时,CAS 就显得无能为力
如何解决原子性
- 加锁,使用synchronized
- 使用
AtomicReference类,该类通过将多个变量封装在一个对象中,然后通过AtomicReference进行操作,保证对象的原子操作。
乐观锁、悲观锁
1.什么是乐观锁、悲观锁?
乐观锁:假设不会发生冲突,认为每次读取数据时,不会有人对数据进行修修改,所以不会加锁。在提交数据时检查数据是否被修改。如果没有被修改则提交成功,如果被修改则提交失败,进行重试。
悲观锁:假设会发生冲突,认为每次读取数据时,会有人对数据进行修修改,所以每次读取数据时都加锁。
2、如何实现乐观锁?
版本号或时间戳
- 一般是在数据表中加上一个版本号
version字段,表示数据被修改的次数。 - 线程A更新数据时,在读取数据的同时,也会读取版本号的字段值。
- 当更新要提前时,比较之前读取到的版本号的字段值与当前版本号的字段相等时才会更新
- 不相等,则一直重复操作,直到更新成功