Java EE初阶——线程安全

发布于:2025-05-17 ⋅ 阅读:(19) ⋅ 点赞:(0)

1. 线程的状态

1. 线程状态分类(Thread.State 枚举)

Java 定义了 6 种线程状态,这些状态均由 java.lang.Thread.State 枚举表示:

  1. NEW(新建)
    线程对象已创建,但尚未调用 start() 方法。此时线程尚未启动,只是一个普通的 Java 对象。

  2. RUNNABLE(可运行)
    线程已调用 start() 方法,正在 JVM 中运行。该状态包含两种实际情况:

    • READY(就绪):线程已获取除 CPU 外的所有资源,等待操作系统调度。
    • RUNNING(运行中):线程正在 CPU 上执行。
  3. BLOCKED(阻塞)
    线程因等待监视器锁(如进入 synchronized 块 / 方法)而被阻塞。线程会在获取锁后恢复为 RUNNABLE 状态。

  4. WAITING(无限期等待)
    线程因调用以下方法而进入无限期等待状态,必须等待其他线程显式唤醒:

    • Object.wait()
    • Thread.join()
    • LockSupport.park()
    • 唤醒条件

      • notify()/notifyAll()

      • 目标线程终止(针对 join()

  5. TIMED_WAITING(限期等待)
    线程因调用以下带超时参数的方法而进入限期等待状态,超时后自动唤醒:

    • Thread.sleep(long millis)
    • Object.wait(long timeout)
    • Thread.join(long millis)
    • LockSupport.parkNanos()
    • LockSupport.parkUntil()
  6. TERMINATED(终止)
    线程执行完毕(run() 方法正常退出)或因异常终止,线程生命周期结束。

2. 线程的状态和转移

1.  NEW RUNNABLE TERMINATED 状态的转换
public class ThreadDomo1 {
    public static void main(String[] args) {
        Thread t = new Thread(()->{
            for(int i=0;i<1;i++){

            }
        });
        System.out.println(t.getState());//NEW
        t.start();
        while(t.isAlive()){//线程存活
            System.out.println(t.getState());//RUNNABLE
        }
        System.out.println(t.getState());//TERMINATED
    }
}
  1. 线程状态不可逆:线程一旦进入 TERMINATED 状态,无法再次启动(调用 start() 会抛出 IllegalThreadStateException)。
  2. BLOCKED 与 WAITING 的区别
    • BLOCKED 是因等待监视器锁而阻塞。
    • WAITING/TIMED_WAITING 是主动调用方法进入等待状态,需显式唤醒或超时。

2. 线程安全

Java 标准库中的线程安全类

1. Java 标准库中很多都是线程不安全的. 这些类可能会涉及到多线程修改共享数据, ⼜没有任何加锁措施.
ArrayList
LinkedList
HashMap
TreeMap
HashSet
TreeSet
StringBuilder
2. 但是还有⼀些是线程安全的. 使⽤了⼀些锁机制来控制.
Vector (不推荐使⽤)
HashTable (不推荐使⽤)
ConcurrentHashMap
StringBuffer

3. 还有的虽然没有加锁, 但是不涉及 "修改", 仍然是线程安全的
String

线程安全是多线程编程中的核心概念,指的是在多线程环境下,程序的行为和结果与单线程环境下一致,不会出现数据竞争、不一致或其他意外情况。

如果这个代码在单线程环境下运行正确,在多线程环境下产生 bug ,这种情况就称为“线程不安全”或“存在线程安全问题” 。

public class ThreadDomo2 {
    public static int count = 0;
    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(()->{
            for(int i=0;i<50000;i++){
                count++;
            }
        });
        Thread t2 = new Thread(()->{
            for(int i=0;i<50000;i++){
                count++;
            }
        });
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println(count);//预期结果 10w
    }
}

运行代码发现,结果每次都不一样,结果大概在5w-10w之间,这是为什么?

1. 线程不安全原因

1. 【根本原因】线程调度是随机的

操作系统上的线程是“抢占式执行” “随机调度” 

2. 修改共享数据

代码结构:进程中多个线程同时修改同一个变量

  没有问题:1. 一个线程修改一个变量

                    2. 多个线程读取同一个变量

                    3. 多个线程修改不同变量

3. 【直接原因】原子性

多线程同时修改同一个变量操作不是原子操作

count++; 由三个指令构成:

  1. load  从内存中读取数据到 cpu 寄存器

  2. add 把寄存器数值 +1

  3. save 把寄存器的值写回到 内存 中

t1 和 t2 是并发执行的,可能交错执行这三步,导致部分增量丢失。

1,2 为线程安全,其余都为线程不安全

关键在于,要确保前一个线程 save 之后,第二个线程再 load ,否则第二个线程 load 到的结果就是第一个线程自增前的结果,两次自增就只 +1

即一个线程执行 1-n(基本为1次)这自增,被另一个线程覆盖成自增 1 次的情况。

4. 可见性

可⻅性指⼀个线程对共享变量值的修改,能够及时地被其他线程看到.
Java 内存模型 (JMM): Java虚拟机规范中定义了Java内存模型.
⽬的是屏蔽掉各种硬件和操作系统的内存访问差异,以实现让Java程序在各种平台下都能达到⼀致的并发效果.

线程之间的共享变量存在 主内存 (Main Memory).
每⼀个线程都有⾃⼰的 "⼯作内存" (Working Memory) .
当线程要读取⼀个共享变量的时候, 会先把变量从主内存拷⻉到⼯作内存, 再从⼯作内存读取数据.
当线程要修改⼀个共享变量的时候, 也会先修改⼯作内存中的副本, 再同步回主内存.
所谓的 "主内存" 才是真正硬件⻆度的 "内存". ⽽所谓的 "⼯作内存", 则是指 CPU 的寄存器和⾼速缓存.
CPU 访问⾃⾝寄存器的速度以及⾼速缓存的速度, 远远超过访问内存的速度(快了 3 - 4 个数量级, 也就是⼏千倍, 上万倍).
import java.util.Scanner;

public class ThreadDomo3 {
    public static int flg = 1;
    //使用 volatile 关键字
    //public static volatile int flg = 1;
    public static void main(String[] args) throws InterruptedException {
        Scanner scan = new Scanner(System.in);
        Thread t1 = new Thread(()->{
            while(flg==1){ // 循环检查 flg 的值
               // 空循环,等待 flg 变为非 1
            }
            System.out.println("t1 线程结束");
        });
        Thread t2 = new Thread(()->{
            System.out.print("请输入flg的值:");
            flg = scan.nextInt();// 从控制台读取输入,修改 flg 的值
            System.out.println("t2 线程结束");
        });
        t1.start();
        t2.start();
    }
}

   

使用 volatile 关键字

在该代码中,我们预期是通过 t2 线程输入一个非 1 数,使 t1 线程结束,事实却是我们输入非 1 整数,t1 线程并未结束,这是为什么?

while(flg == 1);核心指令有两条:

1. load 读取内存中 flg 的值到cpu寄存器中

2. 拿寄存器中获取的值与1进行比较(条件跳转指令)

频繁执行 load 操作和条件跳转,load 操作执行的结果,每次都是一样的,且 load 操作开销远远高于条件跳转,访问寄存器的操作速度远远超过访问内存,此时 jvm 就可能做出代码优化,优化掉 load 操作,以提高循环的执行速度。却导致 t2 线程对共享变量的修改无法及时被 t1 线程看到。造成线程不安全。

这种优化被称为 循环不变代码外提(Loop Invariant Code Motion),它将循环内不变的操作(如 load)移到循环外,大幅提高执行效率。但在多线程环境下,这种优化会导致 内存可见性问题:即使其他线程修改了 flg 的值,执行优化后的线程仍使用寄存器中的旧值。

volatile 关键字

volatile 是一个用于修饰变量的关键字,主要用于保证变量的内存可见性禁止指令重排序

保证可见性
每次访问变量必须要重新读取内存,而不会优化到寄存器/缓存中
代码在写⼊ volatile 修饰的变量的时候,
   • 改变线程⼯作内存中volatile变量副本的值
   • 将改变后的副本的值从⼯作内存刷新到主内存
代码在读取 volatile 修饰的变量的时候
   • 从主内存中读取volatile变量的最新值到线程的⼯作内存中
   • 从⼯作内存中读取volatile变量的副本

禁止指令重排序

针对被 volatile 修饰的变量必须都要重新读取内存,而不会优化到寄存器/缓存中

  • 原理volatile 变量会插入内存屏障(Memory Barrier),禁止编译器和处理器对指令进行重排序

5. 指令重排序

  • 编译器重排序:编译器为优化性能,可能改变代码的执行顺序。
  • 处理器重排序:处理器为提高指令执行效率,可能对指令进行乱序执行。

2. synchronized 关键字 - 监视器锁 monitor lock

针对线程不安全原因3,使用 加锁 的方式,将非原子指令打包成一个整体,确保同一时间,只有该线程可以执行此非原子指令。

synchronized 关键字是实现线程同步的核心机制之一,它基于 ** 监视器锁(Monitor Lock)** 来保证同一时间只有一个线程可以执行被保护的代码块或方法

1、监视器锁的底层原理

  1. 每个对象都有一个监视器锁
    Java 中每个对象(包括类实例和数组)都关联着一个监视器锁(也称为内部锁或互斥锁)。当一个线程试图访问被 synchronized 保护的代码时,它必须先获取该对象的监视器锁。

  2. 锁的获取与释放

    • 获取锁:线程进入 synchronized 代码块前,必须获取对象的监视器锁。如果锁已被其他线程持有,则当前线程会被阻塞,进入锁的等待队列。
    • 释放锁:线程退出 synchronized 代码块时,会自动释放监视器锁,唤醒等待队列中的其他线程竞争锁。
  3. JVM 实现
    监视器锁的实现依赖于对象头中的 Mark Word。当对象被锁定时,Mark Word 会存储指向锁记录的指针,不同状态(偏向锁、轻量级锁、重量级锁)下的存储结构不同

2. synchronized 的使用方式

1. 同步实例方法(锁对象为 this)

使用当前对象实例(this)作为锁

直接修饰普通⽅法

public class SynchronizedDomo {
    public synchronized void method(){
        //...
    }
}
  • 锁对象:隐式使用当前对象实例(this)。
  • 作用范围:整个方法体。
  • 字节码层面:JVM 使用 ACC_SYNCHRONIZED 标志来标记方法,当线程调用该方法时,会自动获取锁并在方法退出时释放锁。

同步代码块

public class SynchronizedDomo {
    public void method() {
        synchronized (this) {
            // 同步代码
        }
    }
}

  • 锁对象:显式指定为 this(当前对象实例)。
  • 作用范围:仅包含在 {} 内的代码。
  • 字节码层面:使用 monitorenter 和 monitorexit 指令实现锁的获取和释放。
2. 同步静态方法(锁对象为类的 Class 对象)

使用类的 Class 对象(即 SynchronizedDomo1.class)作为锁

synchronized 修饰静态方法

public class SynchronizedDomo {
    public static synchronized void method(){
        //...
    }
}
  • 锁对象:隐式使用当前类的 Class 对象(如 SynchronizedDomo1.class)。
  • 作用范围:整个静态方法体。
  • 字节码层面:JVM 使用 ACC_SYNCHRONIZED 标志来标记静态方法,当线程调用该方法时,会自动获取类的 Class 对象锁并在方法退出时释放锁。

同步静态代码块

public class SynchronizedDomo {
    public static void method() {
        // 反射 类名.class 获取当前类的 class 对象
        synchronized (SynchronizedDomo1.class) {
            // 同步代码
        }
    }
}
  • 锁对象:显式指定为当前类的 Class 对象。
  • 作用范围:仅包含在 {} 内的代码。
  • 字节码层面:使用 monitorenter 和 monitorexit 指令实现锁的获取和释放。
3. 同步代码块,指定锁对象(locker)
public class SynchronizedDomo1 {
    //创建锁对象(锁对象可以是任意Object)
    private Object locker = new Object();
    public void method(){
        synchronized (locker){
            //...
        }
    }
}
    代码实例
    public class ThreadDomo3 {
        public 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<50000;i++){
                    // 使用locker作为锁,进入同步块前会获取锁
                    synchronized (locker){
                        count++;
                    }// 退出同步块时自动释放锁
                }
            });
            Thread t2 = new Thread(()->{
                for(int i=0;i<50000;i++){
                    // 使用locker作为锁,进入同步块前会获取锁
                    synchronized (locker){ 
                        count++;
                    } // 退出同步块时自动释放锁
                    //count++;
                }
            });
            // 启动两个线程
            t1.start();
            t2.start();
            // 主线程等待两个线程执行完毕
            t1.join();
            t2.join();
            System.out.println(count);//100000
        }
    }

    3. 互斥

    • 互斥 指同一时间只允许一个线程访问共享资源,其他线程必须等待。

    • 通过 锁(Lock) 或 同步机制(如 synchronized)实现。

    作用

    • 保证原子性:防止多个线程同时修改共享数据导致的数据不一致。
    • 维护可见性:确保一个线程对共享变量的修改能被其他线程正确看到。
    • 防止多个线程同时修改共享数据(如 count++),造成线程不安全

    4. 锁竞争

    锁竞争是指多个线程同时尝试获取同一把锁时发生的竞争现象。当锁被一个线程持有时,其他线程必须等待,从而导致线程阻塞上下文切换,降低程序性能。

    竞争程度 表现 解决方案
    低竞争 线程偶尔阻塞,性能影响小 无优化必要
    高竞争 大量线程阻塞,CPU空转 减小锁粒度、无锁算法

    在上述代码中,两个线程访问共享资源 count, t1 线程进行了同步保护,t2 线程直接访问,就不会形成锁竞争,t2 线程可能看不到 t1 线程对 count 的修改,count ++的原子性被破坏,造成线程不安全。

         

    5. 可重入

    可重入是指同一个线程可以多次获取同一把锁而不会被阻塞可重入锁会记录锁的持有线程和重入次数,当线程退出同步块时,只有重入次数降为 0 才会真正释放锁。

    • 实现原理

      • synchronized 通过 锁计数器 记录重入次数。

      • ReentrantLock 通过 getHoldCount() 获取重入次数。

    Java 中的可重入锁

    • synchronized 关键字:隐式支持可重入。
    • ReentrantLock:显式支持可重入,可通过 lock() 和 unlock() 方法控制。
    public class SynchronizedDomo4 {
        public static int count = 0;
        public static void main(String[] args) throws InterruptedException {
            Object locker = new Object();
            Thread t = new Thread(()->{
                // 真正加锁,同时记录锁的持有线程
                synchronized(locker){ // 第一次获取locker锁,锁计数器为1
                    count++;
                    synchronized (locker){ // 第二次获取同一个locker锁,锁计数器为2
                        count++;
                        synchronized (locker){ // 第三次获取同一个locker锁,锁计数器为3
                            count++;
                        }//解锁,锁计数器为2
                    }//解锁,锁计数器为1
                }//真正解锁,锁计数器为0
            });
            t.start();
            t.join();
            System.out.println(count);//3
        }
    }
    public class SynchronizedDomo5 {
        public void A(){
            synchronized (this){// 子线程获取 this 锁,锁计数器+1 → 1
                B();
            }
        }
        public void B(){
            C();
        }
        public void C(){
            D();
        }
        public void D(){
            synchronized (this){// 子线程再次获取 this 锁,计数+1 → 2(已持有锁,可重入)
                System.out.println("hello");
            }
        }
        public static void main(String[] args) throws InterruptedException {
            SynchronizedDomo5 s = new SynchronizedDomo5();
            Thread t = new Thread(()->{
                s.A();
            });
            t.start();
        }
    }

    虽然 A() 和 D() 都使用 synchronized (this) 加锁,但由于锁是可重入的,同一个线程可以在持有锁的状态下嵌套调用其他同步方法,不会导致死锁。

    概念 互斥(Mutual Exclusion) 锁竞争(Lock Contention) 可重入性(Reentrancy)
    目标 保护共享资源 减少锁冲突 避免自我阻塞
    实现手段 锁机制 锁优化或无锁算法 锁计数器
    关联性 互斥导致锁竞争 高竞争降低性能 可重入减少死锁
    关键点 同一时间只有一个线程能访问共享资源。 多个线程争夺同一把锁,导致阻塞和上下文切换。 同一个线程可多次获取同一把锁。

    6. 死锁

    两个或多个线程互相持有对方需要的资源,导致所有线程都无法继续执行的状态。

    死锁的四个必要条件(Coffman条件)

    1. 互斥条件:资源不能被共享,同一时间只能被一个线程占用。
    2. 占有并等待:线程至少已经持有一个资源,同时请求其他线程持有的资源。
    3. 不可抢占:线程已获得的资源不能被其他线程强行抢占,只能自己释放。
    4. 循环等待:存在一个线程的循环等待链,每个线程都在等待下一个线程所占用的资源。

    public class SynchronizedDomo6 {
        public static void main(String[] args) {
            Object locker1 = new Object();
            Object locker2 = new Object();
            Thread t1 = new Thread(()->{
                synchronized (locker1){
                    //休眠1s,为线程2争取获得locker2的时间
                    try {
                        Thread.sleep(1000);
                    } catch (InterruptedException e) {
                        throw new RuntimeException(e);
                    }
                    //尝试获取locker2
                    synchronized (locker2){
                        System.out.println("t1");
                    }
                }
            });
            Thread t2 = new Thread(()->{
                synchronized (locker2){
                    //休眠1s,为线程1争取获得locker1的时间
                    try {
                        Thread.sleep(1000);
                    } catch (InterruptedException e) {
                        throw new RuntimeException(e);
                    }
                    //尝试获取locker1
                    synchronized (locker1){
                        System.out.println("t2");
                    }
                }
            });
            t1.start();
            t2.start();
        }
    }

     

    • t1 先获取 locker1 锁,然后休眠 1 秒,在这 1 秒内,t2 有机会获取 locker2 锁。
    • t2 先获取 locker2 锁,然后休眠 1 秒,在这 1 秒内,t1 持有 locker1 锁。
    • 当 t1 休眠结束后尝试获取 locker2 锁时,t2 已持有 locker2 锁;而当 t2 休眠结束后尝试获取 locker1 锁时,t1 已持有 locker1 锁。
    • 这样就形成了 t1 等待 t2 释放 locker2 锁,t2 等待 t1 释放 locker1 锁的情况,两个线程相互等待对方释放锁,从而导致死锁。程序卡死。
    如何避免死锁
    1. 破坏互斥条件:不是所有资源都能这样做(如打印机必须互斥使用)

    2. 破坏占有并等待

      • 线程在开始时就获取所有需要的锁,否则不获取任何锁。

      • 使用tryLock()等非阻塞获取锁的方法

    3. 破坏不可抢占条件

      • 使用可响应中断的锁(如ReentrantLock

      • 设置获取锁的超时时间

    4. 破坏循环等待条件

      • 对资源进行排序,按固定顺序获取锁

      • 使用资源分配图算法检测

    7. volatile vs synchronized

    特性 volatile synchronized
    可见性 ✅ 保证可见性 ✅ 保证可见性
    原子性 ❌ 不保证原子性(如 i++ ✅ 保证原子性
    指令重排序 ✅ 禁止重排序 ✅ 禁止重排序
    性能 开销较小,适合轻量级同步 开销较大,适合重量级同步
    使用场景 状态标志、单次初始化、禁止重排序 复合操作、方法或代码块同步


    网站公告

    今日签到

    点亮在社区的每一天
    去签到