线程安全问题

文章目录

线程安全问题

一、概述

1、案例分析

卖票案例:模拟3个窗口同时卖1000张电影票

public class UnsafeThreadTest {
    public static void main(String[] args) {
        // 共享资源:票
        Ticket ticket = new Ticket();

        // 卖票任务
        Runnable task = () -> {
            for (int i = 0; i < 100; i++) {
                ticket.saleTickets();
            }
        };

        // 多线程操作
        new Thread(task, "窗口1").start();
        new Thread(task, "窗口2").start();
        new Thread(task, "窗口3").start();
    }
}

class Ticket {

    private int number = 100;

    public void saleTickets() {
        if (number > 0) {
            number--;
            System.out.println(Thread.currentThread().getName() +
                    "卖了一张票,剩余:" + number);
        }
    }
}
窗口3卖了一张票,剩余:99
窗口2卖了一张票,剩余:99
窗口1卖了一张票,剩余:99
窗口2卖了一张票,剩余:98
窗口3卖了一张票,剩余:97
窗口3卖了一张票,剩余:96

可以看到,三个窗口卖了同一张票,显然出现了线程安全问题。

2、什么是线程安全问题?

线程安全问题是指:多个线程 同时操作 共享资源 而导致的数据不一致或不正确的问题。

  • 如果只有读操作,而没有写操作,一般来说是线程安全的。

3、临界区 和 竞态条件

临界区和竞态条件是并发编程中常用的两个概念:

  • 临界区(Critical Section)

    一段在 并发执行 和 顺序执行 时有着不同运行结果的代码片段。

  • 竞态条件(Race Condition)

    多个线程同时竞争执行临界区的代码,由于执行顺序不同而导致结果无法预测,称之为发生了竞态条件。

要想防止竞态条件的发生,就要确保临界区以原子方式执行:

  • 可以通过加锁、使用原子操作、使用线程安全的数据结构等方式

4、JUC并发包

JUC 是 Java Util Concurrent 的缩写,是 Java 中用于并发编程的工具包,提供了一系列线程安全的工具类、数据结构和执行器框架,用于简化多线程编程,并提高并发性能。

JUC 提供了以下几个主要的组件:

  1. 原子变量类(Atomic Variables):

    包括 AtomicIntegerAtomicLongAtomicBoolean 等,

    用于在多线程环境下操作原子性的变量,通常使用 CAS(Compare and Swap)操作来实现。

  2. 锁框架(Lock Framework):

    包括 Lock 接口、ReentrantLockReentrantReadWriteLockStampedLock 等,

    提供了比传统的 synchronized 关键字更灵活、可扩展的锁机制,支持更多的锁定方式和功能。

  3. 并发集合(Concurrent Collections):

    包括 ConcurrentHashMapCopyOnWriteArrayListCopyOnWriteArraySet 等,

    这些集合类提供了线程安全的实现,可以在多线程环境下安全地进行操作。

  4. 并发工具类(Concurrent Utilities):

    包括 CountDownLatchCyclicBarrierSemaphoreExchanger 等,

    用于协调多个线程的执行顺序、控制并发数量等。

  5. 线程池框架(Executor Framework):

    包括 ExecutorExecutorServiceThreadPoolExecutorScheduledExecutorService 等,

    提供了管理线程生命周期、调度执行任务、控制线程池大小等功能,简化了多线程编程中线程的创建和管理。

JUC 的引入提供了更丰富的并发编程工具和更灵活的线程控制机制,能够更好地满足多线程编程的需求。

5、解决方案

1)悲观锁

  • 悲观的含义:

    认为在并发访问时会发生冲突,因此在处理数据之前加锁,使得整个数据处理过程中,数据处于锁定状态。

  • 悲观锁/互斥锁:

    一个线程一旦获取到锁,其他需要锁的线程就会阻塞BLOCKED,等待锁的释放。

  • 常用的悲观锁:

    synchronizedLock

  • 悲观锁的优缺点:

    优点:简单直观,稳定可靠,并发适用于写操作频繁的场景。

    缺点:需要获取和释放锁,性能开销大;加锁粒度控制不当,也会导致性能下降;可能导致死锁。

2)乐观锁

  • 乐观的含义:

    在并发访问时不会发生冲突,因此不需要加锁。

  • 乐观锁:

    当多个线程尝试更新同一资源时,乐观锁会进行版本检查或者比较操作,以确定是否发生了冲突。

    如果未被更新,则修改成功;如果已被更新,则修改失败,可以 自旋重试 或 不再重试。

  • 乐观锁的应用:

    原子操作类(基于CAS)

  • 乐观锁的优缺点:

    优点:不需要加锁,减少了锁的竞争和开销,适用于读操作频繁的场景。

    缺点:自旋消耗资源、ABA问题

二、Java内存模型(JMM)

1、什么是JMM

Java内存模型(Java Memory Model,JMM)是一种规范,用于定义在并发编程中,Java程序中各个线程之间如何通过内存进行通信。JMM规定了线程如何与主内存和工作内存进行交互,以及如何确保多线程环境下的内存可见性、原子性和有序性。

JMM通过限制和规范多线程程序中的内存访问,确保了程序的正确性和可预测性。程序员在编写并发程序时,需要遵循JMM的规范,使用同步机制(如锁、volatile、synchronized等)来保证线程间的内存可见性和操作的原子性。

2、内存交互规定

  • 所有变量都存储在主内存(主内存是所有线程共享的内存区域)
  • 每个线程创建时,JVM都会为其创建一个工作内存(用于存储线程私有的数据,如共享变量副本)
  • 线程不能直接操作主内存中的共享变量,线程对变量的所有操作都是在工作内存中进行的。

在这里插入图片描述

工作内存是线程私有的,不同的线程间无法访问对方的工作内存,线程间的通信/传值必须通过主内存来完成:

Java 内存模型定义了 8 个操作来完成主内存工作内存的交互操作,每个操作都是原子

在这里插入图片描述

  1. Lock

    作用于主内存,将一个变量标识为被一个线程独占状态(对应 monitorenter)

  2. Read

    作用于主内存,把一个变量值从主内存读取到线程的工作内存中,以便后续load使用。

  3. Load

    作用于工作内存,在 read 之后执行,把 read 得到的值放入工作内存的变量副本中

  4. Use

    作用于工作内存,把工作内存中的一个变量值传递给执行引擎使用。

  5. Assign

    作用于工作内存,把一个从执行引擎接收到的值赋给工作内存的变量。

  6. Store

    作用于工作内存,把工作内存中的一个变量的值传送到主内存中,以便后续write使用。

  7. Write

    作用于主内存,在 store 之后执行,把 store 得到的值放入主内存的变量中

  8. Unclock

    作用于主内存,将一个变量从独占状态释放出来,释放后的变量才可以被其他线程锁定(对应 monitorexit)

3、happens-before 原则

happens-before 是 JMM 中的一个重要概念,用于描述在多线程环境下操作之间的顺序性关系。

  • 具体来说,如果 操作A “happens-before” 操作B,那么在执行时,操作A 的效果将对 操作B 可见。

1)程序顺序规则

在同一个线程中,前一个操作 happens-before 后一个操作

  • 操作1(对变量 x 的写操作)对于 操作2(对变量 x 的读操作)是可见的。
int x = 0;
int y = 0;
x = 1; // 操作1
y = x; // 操作2

2)监视器锁规则

释放监视器锁(synchronized 块或方法的结束) happens-before 获取监视器锁(synchronized 块或方法的开始)

  • 操作1(t1线程 释放锁m 之前对 x 的写)对于 操作2(t2线程 获取锁m 之后对 x 的读)是可见的。
public class MonitorLockRuleTest {

    static int x;

    public static void main(String[] args) {
        Object m = new Object();

        new Thread(() -> {
            synchronized (m) {
                x = 10;
            }
        }, "t1").start();

        new Thread(() -> {
            synchronized (m) {
                System.out.println(x);
            }
        }, "t2").start();
    }

}

3)volatile 变量规则

对一个 volatile 变量的写操作 happens-before 于 对一个 volatile 变量的读操作

  • 操作1(t1线程 对 volatile变量x 的写)对于 操作2(t2线程 对 volatile变量x 的读)是可见的。
public class VolatileRuleTest {

    volatile static int x;

    public static void main(String[] args) {
        new Thread(() -> x = 10, "t1").start();
        new Thread(() -> System.out.println(x), "t2").start();
    }
}

4)线程启动规则

一个线程的启动操作 happens-before 该线程的任何操作。

  • 操作1(线程 start 前对变量的写)对于 操作2(线程 start 后对变量的读)是可见的。
public class ThreadStartRuleTest {

    static int x;

    public static void main(String[] args) {
        x = 10;
        new Thread(() -> System.out.println(x)).start();
    }
}

5)线程终止规则

一个线程的所有操作 happens-before 其他线程检测到该线程已经终止。

  • 操作1(线程结束前对变量的写)对于 操作2(其它线程得知它结束后的读)是可见的。

“其它线程得知它结束”包括:

  • 其它线程调用 t.isAlive() 判断 或 t.join()等待它结束
public class ThreadTerminationRuleTest {

    static int x;

    public static void main(String[] args) throws InterruptedException {
        Thread t = new Thread(() -> x = 10);
        t.start();
        t.join();
        System.out.println(x);
    }

}

6)中断规则

对线程 interrupt() 方法的调用 happens-before 其他线程检测到被中断线程发生中断。

  • 操作1(线程 t1 打断 t2 前对x的写)对于 操作2(main线程得知 t2 被打断后对x的读)是可见的。
public class InterruptionRuleTest {

    static int x;

    public static void main(String[] args) {
        Thread t2 = new Thread(() -> {
            while (true) {
                if (Thread.currentThread().isInterrupted()) {
                    System.out.println(x);
                    break;
                }
            }
        }, "t2");
        t2.start();

        new Thread(() -> {
            try {
                TimeUnit.SECONDS.sleep(1);
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
            x = 10;
            t2.interrupt();
        }, "t1").start();

        while (!t2.isInterrupted()) {
            Thread.yield();
        }

        System.out.println(x);
    }

}

7)终结器规则

一个对象的初始化完成(构造函数执行结束)happens-before 该对象的finalize()方法的开始。

public class FinalizerRuleTest {
    int x;

    public FinalizerRuleTest() {
        this.x = 10;
    }

    @Override
    protected void finalize() {
        System.out.println(x); // 10
    }

    public static void main(String[] args) throws Throwable {
        FinalizerRuleTest finalizerRuleTest = new FinalizerRuleTest();
        finalizerRuleTest.finalize();
    }

}

8)传递性规则

如果操作A先行发生于操作B,操作B先行发生于操作C,那就可以得出操作A先行发生于操作C的结论。

public class TransitivityTest {
    static boolean flag1 = false;
    volatile static boolean flag2 = false;

    public static void main(String[] args) throws InterruptedException {
        new Thread(() -> {
            while (true) {
                if (flag1) {
                    System.out.println("flag1 exit");
                    break;
                }
                if (flag2) {
                    System.out.println("flag2 exit");
                    break;
                }
            }
        }, "t").start();

        TimeUnit.SECONDS.sleep(1);

        System.out.println("停止t线程");
        flag1 = true;
        flag2 = true;
    }
}
停止t线程
flag1 exit

三、并发编程三大特性

一个并发程序,必须保证原子性、可见性、有序性,否则就可能导致运行结果不正确。

1、原子性 - 线程切换

原子性:
	一个/多个操作在执行过程中不会被线程调度机制打断,要么全部执行,要么都不执行。
	操作一旦开始,就一直运行到结束,中间不会有任何上下文切换(context switch)。

原子性问题:
	一个操作的多个步骤间发生了线程的上下文切换

如何保证:
	1. 悲观锁:synchronized、Lock
	2. 乐观锁:版本号、CAS、原子操作类

1)问题示例

/**
 * 原子性问题:同时对共享变量加减相同次数,结果不为0
 */
public class AtomicityUnsafeTest {

    private static int num = 0;

    public static void main(String[] args) {
        new Thread(() -> {
            for (int i = 0; i < 100000; i++) {
                num++;
            }
        }, "t1").start();

        new Thread(() -> {
            for (int i = 0; i < 100000; i++) {
                num--;
            }
        }, "t2").start();

        while (Thread.activeCount() > 2) {
            // main线程、gc线程
        }

        System.out.println(num);  // -909(理论上应该是0)
    }

}

2)问题分析

public class AtomicityTest {
    private static int num = 0;
    private static void add() {
        num++;
    }
}
$ javap -p -c AtomicityTest.class 
  ........
  private static void add();
    Code:
       0: getstatic     #2                  // Field num:I
       3: iconst_1
       4: iadd
       5: putstatic     #2                  // Field num:I
       8: return

可以看到,num++可以分解为3步:

  1. 从静态区获取num的值
  2. 执行++操作
  3. 将修改后的值写回静态区

执行以上3个步骤时,可能会发生线程切换,因此num++不是一个原子性操作,会发生原子性问题。

3)解决方案

共享变量使用原子操作类(除此以外,还可以使用加锁等方式,此处仅展示原子操作类的示例)

public class AtomicitySafeTest {
    // 原子操作类
    private static AtomicInteger num = new AtomicInteger();

    public static void main(String[] args) {
        new Thread(() -> {
            for (int i = 0; i < 100000; i++) {
                num.getAndIncrement();
            }
        }, "t1").start();

        new Thread(() -> {
            for (int i = 0; i < 100000; i++) {
                num.getAndDecrement();
            }
        }, "t2").start();

        while (Thread.activeCount() > 2) {
            // main线程、gc线程
        }

        System.out.println(num);  // 0
    }

}

2、可见性 - 工作内存

可见性:
    多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值。

可见性问题:
	若两个线程在不同的CPU,那么线程1改变了i的值还没刷新到主存,线程2又使用了i,
	那么这个i值肯定还是之前的,线程1对变量的修改线程2没看到,这就是可见性问题。 

如何保证:
	1. volatile关键字
	2. synchronized关键字

1)问题示例

/**
 * 可见性问题:线程t不知道main线程对共享变量的修改,没有退出循环
 */
public class VisibilityUnsafeTest {

    static boolean run = true;

    public static void main(String[] args) throws InterruptedException {
        new Thread(() -> {
            while (true) {
                if (!run) break;
            }
            System.out.println("run发生了变化");
        }, "t").start();

        TimeUnit.SECONDS.sleep(1);

        System.out.println("停止t线程");
        run = false;
    }

}

2)问题分析

  1. 初始状态, t 线程刚开始从主内存读取了 run 的值到工作内存。

在这里插入图片描述

  1. 因为 t 线程要频繁从主内存中读取 run 的值,JIT 编译器会将 run 的值缓存至自己工作内存中的高速缓存中,

    减少对主存中 run 的访问,提高效率。

在这里插入图片描述

  1. 1 秒之后,main 线程修改了 run 的值,并同步至主存,而 t 线程还是从自己工作内存的缓存中读取 run 的值,还是旧值。

在这里插入图片描述

3)解决方案 - volatile

/**
 * volatile 保证 可见性
 */
public class VisibilitySafeTest1 {

    // volatile修饰
    volatile static boolean run = true;

    public static void main(String[] args) throws InterruptedException {
        new Thread(() -> {
            while (true) {
                if (!run) break;
            }
            System.out.println("run发生了变化");
        }, "t").start();

        TimeUnit.SECONDS.sleep(1);

        System.out.println("停止t线程");
        run = false;
    }

}

4)解决方案 - synchronized

/**
 * synchronized 保证 可见性
 */
public class VisibilitySafeTest2 {

    static boolean run = true;

    public static void main(String[] args) throws InterruptedException {
        Object lock = new Object();

        new Thread(() -> {
            while (true) {
                // synchronized同步
                synchronized (lock) {
                    if (!run) break;
                }
            }
            System.out.println("run发生了变化");
        }, "t").start();

        TimeUnit.SECONDS.sleep(1);

        System.out.println("停止t线程");
        run = false;
    }

}

原理分析:

  • 当一个线程进入synchronized代码块或方法时,它会获取同步锁,

    在获取同步锁时,线程会清空工作内存中的共享变量值,并从主内存中重新获取最新的值。

  • 当一个线程退出synchronized代码块或方法时,它会释放同步锁。

    在释放同步锁时,线程会将工作内存中的共享变量的值刷新到主内存中,这样其他线程就能够看到最新的值。

因此,使用synchronized关键字会刷新线程的工作内存,确保共享变量的修改被及时反映到主内存中,同时也会确保从主内存中获取最新的共享变量值。

5)扩展

在示例的死循环中加入 System.out.println() 会发现,线程 t 也能正确看到对 flag 变量的修改了

public class VisibilitySafeTest3 {

    static boolean run = true;

    public static void main(String[] args) throws InterruptedException {
        new Thread(() -> {
            while (true) {
                System.out.println();
                if (!run) break;
            }
            System.out.println("run发生了变化");
        }, "t").start();

        TimeUnit.SECONDS.sleep(1);

        System.out.println("停止t线程");
        run = false;
    }

}

看一下System.out.println()的源码,可以看到使用了synchronized代码块

/**
 * System.out.println()源码
 */
public void println(String x) {
    synchronized (this) {
        print(x);
        newLine();
    }
}

当然,不推荐用这种方式保证原子性,这里只是做一个了解。

3、有序性 - 指令重排

有序性:
	程序按照代码的先后顺序执行。
	
有序性问题:
	发生指令重排(程序没有按代码顺序执行),从而在多线程的时候出现问题(单线程不会出现问题)

如何保证:
	1. volatile关键字(可以禁止指令重排)
	2. synchronized关键字(注意,synchronized并不能禁止指令重排)
	   	如果变量操作都在临界区内(synchronized内部),是可以保证有序性的,
	   	但是如果变量逃逸在临界区之外(参考双重校验锁),就又可能发生指令重排,导致有序性问题

代码的执行过程:
	源代码 -> 编辑器优化重排 -> 指令并行重排 -> 内存系统重排 -> 代码执行

1)问题示例

/**
 * 有序性问题:相同的代码可能会输出不同的结果
 */
public class OrderUnsafeTest {
    static int num = 0;
    static boolean flag = false;

    public static void main(String[] args) {
        Thread thread1 = new Thread(() -> {
            num = 1;
            flag = true;
        });

        Thread thread2 = new Thread(() -> {
            if (flag) {
                System.out.println(num + num);
            } else {
                System.out.println(num);
            }
        });

        thread1.start();
        thread2.start();
    }
}

可能输出的结果:

  • 情况1:线程2 先执行,这时 flag = false,所以进入 else 分支,结果为 1
  • 情况2:线程1 先执行 num = 1,还未执行 flag = true,此时线程2 执行,结果为1(和情况1一致)
  • 情况3:线程1 执行完 num = 1 和 flag = true,线程2 执行,这回进入 if 分支,结果为 2
  • 情况4:线程1 指令重排,先执行 flag = true,还未执行 num = 1,此时线程2 执行,进入 if 分支,结果为 0

可以看到,情况4发生指令重排,导致发生了有序性问题。

2)问题分析 - 指令重排

一般来说,处理器为了提高程序的运行效率,可能会对代码进行优化,它不保证程序中各个语句的执行顺序代码顺序一致,但是它会保证程序在单线程环境最终的执行结果代码顺序执行的结果 是一致的。

注意:指令重排不会影响单线程的执行结果,但是会影响多线程并发执行的结果正确性。

以下面4个语句为例:
	int i = 0;    // 语句1
    int j = 0;    // 语句2
    i = i + 8;    // 语句3
    j = i * i;    // 语句4

可能的执行顺序如下:
    1-2-3-4
    2-1-3-4
    1-3-2-4   

编译器在进行指令重排时,会考虑数据的依赖性问题。拿上面的例子来说:

  • 语句4依赖于语句3,因此语句4一定是在语句3之后执行的。

3)为什么要指令重排

每条计算机指令可以被划分成一个个更小的阶段,如: 取指令 - 指令译码 - 执行指令 - 内存访问 - 数据写回 这 5 个阶段

在这里插入图片描述

在不改变程序结果的前提下,这些指令的各个阶段可以通过重排序组合来实现指令级并行

在这里插入图片描述

这时 CPU 可以在一个时钟周期内,同时运行五条指令的不同阶段,通常被称为五级流水线(Five-stage pipeline)

这虽然不能缩短单条指令的执行时间,但它使得处理器能够在同一时刻执行多条指令,从而提高了指令的处理速度和整体性能。

4)解决方案 - volatile

/**
 * volatile 保证 有序性
 */
public class OrderUnsafeTest {
    static int num = 0;

    // volatile修饰
    volatile static boolean flag = false;

    public static void main(String[] args) {
        Thread thread1 = new Thread(() -> {
            num = 1;
            flag = true;
        });

        Thread thread2 = new Thread(() -> {
            if (flag) {
                System.out.println(num + num);
            } else {
                System.out.println(num);
            }
        });

        thread1.start();
        thread2.start();
    }
}

这里volatile通过内存屏障来限制指令重排,从而保证了有序性。

四、volatile

1、volatile的作用

  • 保证可见性
    • volatile 变量的写操作,会同步到主内存当中。
    • volatile 变量的读操作,加载的是主内存中的最新数据。
  • 保证有序性
    • 通过内存屏障来限制指令重排

注意:volatile并不能保证原子性!

2、内存屏障(Memory Barrier)

内存屏障的分类(参考JSR-133

屏障类型示例说明
LoadLoadLoad1-LoadLoad-Load2保证 Load1的读操作 在 Load2及其后的读操作 之前执行
LoadStoreLoad1-LoadStore-Store1保证 Load1的读操作 在 Store1及其后的写操作 之前执行
StoreLoadStore1-LoadLoad-Load1保证 Store1的写操作刷新到主内存后 再执行 Load1及其后的读操作
StoreStoreStore1-LoadLoad-Store2保证 Store1的写操作刷新到主内存后 再执行 Store2及其后的写操作

在实际执行时,编译器可以省略没必要的屏障点,同时在某些处理器上会做进一步的优化。

  • Normal操作 和 Normal操作 之间,是不加屏障的
  • Volatile的写操作 之后如果是 Normal操作,也是不加屏障的
  • Volatile的读操作 之前如果是 Normal操作,也是不加屏障的

在这里插入图片描述

3、volatile 保证 可见性

  • volatile 变量的写操作,会同步到主内存当中。
  • volatile 变量的读操作,加载的是主内存中的最新数据。
/**
 * volatile 保证 可见性
 */
public class VisibilitySafeTest1 {

    // volatile修饰
    volatile static boolean run = true;

    public static void main(String[] args) throws InterruptedException {
        new Thread(() -> {
            while (true) {
                if (!run) break;
            }
            System.out.println("run发生了变化");
        }, "t").start();

        TimeUnit.SECONDS.sleep(1);

        System.out.println("停止t线程");
        run = false;
    }

}

4、volatile 保证 有序性

StoreStore屏障保证了num = 1不会重排到flag = true之后。

/**
 * volatile 保证 有序性
 */
public class OrderUnsafeTest {
    static int num = 0;

    // volatile修饰
    volatile static boolean flag = false;

    public static void main(String[] args) {
        Thread thread1 = new Thread(() -> {
            num = 1;
            // StoreStore屏障
            flag = true;
        });

        Thread thread2 = new Thread(() -> {
            if (flag) {
                System.out.println(num + num);
            } else {
                System.out.println(num);
            }
        });

        thread1.start();
        thread2.start();
    }
}

5、原子性(无法保证)

无法解决多个线程之间指令交错的问题。如下图所示(t1线程执行i++t2线程执行i--

  • 有序性的保证也只是保证了本线程内相关代码不被重排序。

在这里插入图片描述

6、应用:双重校验锁单例

public class DoubleCheckLockSingleton {

    private Singleton() {}
    
    // volatile修饰,禁止指令重排
    private volatile static final Singleton uniqueInstance;
    
    public static Singleton getUniqueInstance() {
        if (uniqueInstance == null) {
            // 没有被实例化才会加锁
            synchronized (Singleton.class) {
                // 可能有多个线程都进入了第一个if,因此需要这里的if再次判断,保证只实例化1次
                if (uniqueInstance == null) {
                    uniqueInstance = new Singleton();
                }
            } 
        }
    	return uniqueInstance;    
    }
}

volatile 关键字很重要。 uniqueInstance = new Singleton(); 这段代码其实是分为三步执行:

  1. uniqueInstance 分配内存空间
  2. 执行构造方法,初始化 uniqueInstance 对象
  3. uniqueInstance 指向分配的内存地址

由于JVM的指令重排,执行顺序可能变为1-3-2。多线程环境下,可能会获取到还没有初始化的实例(还未执行2)

7、实现:两阶段中止模式

public class TwoParseTerminationTest {
    public static void main(String[] args) throws InterruptedException {
        TwoParseTermination twoParseTermination = new TwoParseTermination();
        twoParseTermination.start();
        Thread.sleep(3000);  			
        twoParseTermination.stop();
    }
}

class TwoParseTermination {
    // 监控线程
    Thread monitorThread;
    // 中止标记(需要用volatile修饰,保证共享变量在不同线程间的可见性)
    private volatile boolean stop = false;
    
    // 启动监控线程
    public void start(){
        monitorThread = new Thread(()->{
            while(true) {
                // 判断是否被中止(这里用volatile修饰的stop替代了isInterrupted)
                if (stop){
                    System.out.println("线程结束。。正在料理后事中");
                    break;
                }
                
                try {
                    Thread.sleep(500);
                    System.out.println("正在执行监控的功能");
                } catch (InterruptedException e) {
                }
            }
        }, "monitor");
        monitorThread.start();
    }
    
    // 停止监控线程
    public void stop(){
        stop = true;
        monitorThread.interrupt();
    }
}

五、悲观锁 - Lock

1、概述

jdk1.5 之后,JUC并发包中新增了 Lock接口 及 相关实现类,用来实现同步功能(保证原子性)

package java.util.concurrent.locks;

public interface Lock {
    // 加锁
    void lock();
    void lockInterruptibly() throws InterruptedException;
    // 解锁
    boolean tryLock();
    boolean tryLock(long time, TimeUnit unit) throws InterruptedException;
    // 尝试获取锁
    void unlock();
    
    Condition newCondition();
}

Lock 提供了与 synchronized 类似的同步功能,但需要在使用时手动获取和释放锁,并且多个线程要使用相同的Lock对象

  • lock() 方法是一个阻塞式的加锁方法:

    如果当前锁没有被其他线程持有,那么它会获取到锁并返回 ;

    如果当前锁被其他线程持有,那么调用线程将被阻塞,直到获取到锁为止。

  • tryLock() 方法是一个非阻塞式的加锁方法:

    如果当前锁没有被其他线程持有,那么它会立即获取到锁并返回 true

    如果当前锁被其他线程持有,那么它会立即返回 false,而不会使调用线程进入阻塞状态。

2、使用示例

Lock是接口,这里我们以它的实现类 ReentrantLock 为例,演示Lock的同步功能。

1)lock加锁 & unlock解锁

lock() 方法会阻塞当前线程,直到获取到锁为止。

public class LockTest {

    private static int number = 30;
    private static final Lock lock = new ReentrantLock();
    
    public static void main(String[] args) {
        Runnable task = () -> {
            String currentThread = Thread.currentThread().getName();
            for (int i = 0; i < 10; i++) {
                try {
                    lock.lock();
                    if (number > 0) {
                        number--;
                        System.out.println(currentThread + "卖了一张票,剩余:" + number);
                    }
                } catch (Exception e) {
                    // TODO: handle exception
                } finally {
                    lock.unlock();
                }
            }
        };

        // 多线程操作
        new Thread(task, "窗口1").start();
        new Thread(task, "窗口2").start();
        new Thread(task, "窗口3").start();
    }
}

2)tryLock获取锁

tryLock() 方法尝试获取锁,获取成功返回true,获取失败返回false

public class TryLockTest {

  	// 创建锁对象
    private final Lock lock = new ReentrantLock();

    public static void main(String[] args) {
        final TryLockTest test = new TryLockTest();
        new Thread(test::tryLock, "t1").start();
        new Thread(test::tryLock, "t2").start();
    }

    public void tryLock() {
        String currentThread = Thread.currentThread().getName();
        // 尝试获取锁
        if (lock.tryLock()) {
            try {
                System.out.println(currentThread + "获取锁成功");
                Thread.sleep(2000);
            } catch (Exception e) {
                // TODO: handle exception
            } finally {
              	// 释放锁
                lock.unlock();
                System.out.println(currentThread + "释放了锁");
            }
        } else {
            System.out.println(currentThread + "获取锁失败");
        }
    }
}

3、synchronized 和 Lock 对比

  • synchronized 是内置的Java关键字;Lock 是一个接口。
  • synchronized 无法判断获取锁的状态;Lock 可以通过 tryLock 方法知道有没有成功获取锁。
  • synchronized 会自动释放锁;Lock 需要通过 unlock 方法手动释放锁,不释放会产生死锁。
  • synchronized 没有获取到锁会一直等待下去;Lock 可以通过 tryLock 方法让没有获取到锁的线程中断。
  • synchronized 可重入锁,非公平锁,不可中断;Lock 可重入锁,可设置公平锁(构造),可中断(判断)。
  • synchronized 不支持读写锁;Lock 支持读写锁,可以提高多个线程进行读操作的效率。

synchronized 适用于简单的同步需求,使用起来方便快捷;而 Lock 适用于复杂的同步需求,提供了更多的灵活性和性能优势。

4、公平锁 & 非公平锁

synchronized 是 非公平锁;Lock 默认是非公平锁,也可以设置为公平锁,如:new ReentrantLock(true)

public class ReentrantLock implements Lock, java.io.Serializable {
    // 非公平锁:随机获取锁
    public ReentrantLock() {
        sync = new NonfairSync();
    }

    // 公平锁:先来后到
    public ReentrantLock(boolean fair) {
        sync = fair ? new FairSync() : new NonfairSync();
    }
}

5、可重入锁

可重入锁:允许同一线程多次获取锁。

  • synchronized 和 Lock 都是可重入锁。
  • Lock锁的 lock() 和 unlock() 必须成对出现,不然会发生死锁。
public class ReentrantLockDemo {

    private static final Lock lock = new ReentrantLock();

    public static void main(String[] args) {
        // 创建两个线程,分别调用同一个对象的方法
        new Thread(ReentrantLockDemo::printNumbers, "t1").start();
        new Thread(ReentrantLockDemo::printNumbers, "t2").start();
    }

    public static void printNumbers() {
        lock.lock(); // 获取锁
        try {
            System.out.println(Thread.currentThread().getName() + ":第一次获取锁");
            // 调用另一个需要获取同一把锁的方法
            printLetters();
            Thread.sleep(100); // 模拟其他操作
        } catch (InterruptedException e) {
            // TODO: handle exception
        } finally {
            lock.unlock(); // 释放锁
        }
    }

    public static void printLetters() {
        lock.lock(); // 获取锁
        try {
            System.out.println(Thread.currentThread().getName() + ":重入锁");
        } finally {
            lock.unlock(); // 释放锁
        }
    }
}
t1:第一次获取锁
t1:重入锁
t2:第一次获取锁
t2:重入锁

6、自旋锁

1)原子操作类

public class AtomicInteger extends Number implements java.io.Serializable {
    public final int getAndIncrement() {
        return unsafe.getAndAddInt(this, valueOffset, 1);
    }
}
public final class Unsafe {
    public final int getAndAddInt(Object var1, long var2, int var4) {
        int var5;
        // 自旋操作
        do {
            var5 = this.getIntVolatile(var1, var2);
        } while(!this.compareAndSwapInt(var1, var2, var5, var5 + var4));

        return var5;
    }
    
    public final native boolean compareAndSwapInt(Object o, long offset, int expected, int x);
}

2)自定义自旋锁

private class SpinLock {
    
    AtomicReference<Thread> atomicReference = new AtomicReference<>();

    public void lock() {
        Thread thread = Thread.currentThread();
        // 自旋
        while (!atomicReference.compareAndSet(null, thread)) {
            // do nothing
        }
        System.out.println(Thread.currentThread().getName() + "上锁");
    }

    public void unlock() {
        Thread thread = Thread.currentThread();
        atomicReference.compareAndSet(thread, null);
        
        System.out.println(Thread.currentThread().getName() + "解锁");
    }
}
public class SpinLockTest {

    private static int number = 30;
    private static final SpinLock lock = new SpinLock();

    public static void main(String[] args) {
        Runnable task = () -> {
            String currentThread = Thread.currentThread().getName();
            for (int i = 0; i < 10; i++) {
                try {
                    lock.lock();
                    if (number > 0) {
                        number--;
                        System.out.println(currentThread + "卖了一张票,剩余:" + number);
                    }
                } catch (Exception e) {
                    // TODO: handle exception
                } finally {
                    lock.unlock();
                }
            }
        };

        // 多线程操作
        new Thread(task, "窗口1").start();
        new Thread(task, "窗口2").start();
        new Thread(task, "窗口3").start();
    }

}

3)自定义可重入自旋锁

为了实现可重入锁,我们需要引入一个计数器,用来记录获取锁的线程数。

public class ReentrantSpinLock {

    AtomicReference<Thread> cas = new AtomicReference<>();

    private int count; // 重入次数

    public void lock() {
        Thread thread = Thread.currentThread();
        // 如果当前线程已经获取到了锁,计数器+1,然后返回
        if (thread == cas.get()) {
            count++;
            System.out.println("重入次数+1,count=" + count);
            return;
        }
        // 如果没获取到锁,则通过CAS自旋
        while (!cas.compareAndSet(null, thread)) {
            // do nothing
        }
    }

    public void unlock() {
        Thread thread = Thread.currentThread();
        if (thread == cas.get()) {
            if (count > 0) {
                // 如果大于0,表示当前线程多次获取了该锁,释放锁通过计数器-1来模拟
                count--;
                System.out.println("重入次数-1,count=" + count);
            } else {
                // 如果count==0,可以将锁释放,这样就能保证获取锁的次数与释放锁的次数是一致的了。
                cas.compareAndSet(thread, null);
            }
        }
    }
}
public class ReentrantSpinLockTest {

    private static final ReentrantSpinLock lock = new ReentrantSpinLock();

    public static void main(String[] args) {
        // 创建两个线程,分别调用同一个对象的方法
        new Thread(ReentrantSpinLockTest::printNumbers, "t1").start();
        new Thread(ReentrantSpinLockTest::printNumbers, "t2").start();
    }

    public static void printNumbers() {
        lock.lock(); // 获取锁
        try {
            System.out.println(Thread.currentThread().getName() + ":第一次获取锁");
            // 调用另一个需要获取同一把锁的方法
            printLetters();
            Thread.sleep(1000); // 模拟其他操作
        } catch (InterruptedException e) {
            // TODO: handle exception
        } finally {
            lock.unlock(); // 释放锁
        }
    }

    public static void printLetters() {
        lock.lock(); // 获取锁
        try {
            System.out.println(Thread.currentThread().getName() + ":重入锁");
        } finally {
            lock.unlock(); // 释放锁
        }
    }

}

4)自定义公平自旋锁

public class FairSpinLock {
    // 服务号
    private AtomicInteger serviceNum = new AtomicInteger(1);
    // 排队号
    private volatile AtomicInteger queueNum = new AtomicInteger(0);
    // 存储每个线程的排队号
    private ThreadLocal<Integer> queueNumHolder = new ThreadLocal<>();

    public void lock() {
        int currentQueueNum = queueNum.incrementAndGet();
        queueNumHolder.set(currentQueueNum);
        // 排队号=服务号 才能获取锁
        while (currentQueueNum != serviceNum.get()) {
            // Do nothing
        }
        System.out.println(Thread.currentThread().getName() + "上锁");
    }

    public void unlock() {
        // 释放锁,从ThreadLocal中获取当前线程的排队号
        Integer currentQueueNum = queueNumHolder.get();
        // 服务号+1
        serviceNum.compareAndSet(currentQueueNum, currentQueueNum + 1); 
        System.out.println(Thread.currentThread().getName() + "解锁");
    }
}

7、死锁

两个 或 两个以上的线程在执行的过程中,因争夺资源产生的一种互相等待的现象叫做死锁。产生死锁后,线程会一直等待,无法执行,因此这种现象是我们不希望出现的,要尽量避免其发生。

1)死锁的演示

package thread.lock;

public class DeadLock {
    public static void main(String[] args) {
        // 条件1:多个锁对象
        Object obj1 = new Object();
        Object obj2 = new Object();

        // 条件2:多个线程
        new Thread(() -> {
            while (true) {
                // 条件3:锁的嵌套
                synchronized (obj1) {
                    System.out.println("A线程拿到了锁1");
                    synchronized (obj2) {
                        System.out.println("A线程拿到了锁2");
                    }
                }
            }
        }, "A").start();

        new Thread(() -> {
            // 死循环增大死锁概率
            while (true) {
                synchronized (obj2) {
                    System.out.println("B线程拿到了锁2");
                    synchronized (obj1) {
                        System.out.println("B线程拿到了锁1");
                    }
                }
            }
        }, "B").start();
    }
}	

2)死锁的排查

使用 jps -l 命令定位进程号

$ jps -l
24443 lock.DeadLock

使用 jstack 进程号 命令查看进行信息

在这里插入图片描述

在这里插入图片描述

3)如何避免死锁

死锁产生的3个条件:

  1. 有多把锁
  2. 有多个线程
  3. 有同步代码块嵌套

死锁的避免:只要打破三个条件中的一个,就无法形成死锁。

六、乐观锁 - CAS

1、什么是 CAS ?

CAS是英文单词Compare And Swap的缩写,翻译过来就是比较并替换

Java中的CAS是通过Unsafe类中的native方法实现的

  • 无法直接调用,但可以通过一些封装好的类实现,如原子操作类,底层调用的就是Unsafe类中的方法。
  • 底层实现是通过一条CPU指令cmpxchg,用于在内存位置上执行原子比较和交换操作。
public final class Unsafe {
    public final native boolean compareAndSwapObject(参数);
    public final native boolean compareAndSwapInt(参数);
    public final native boolean compareAndSwapLong(参数);
}

2、CAS 的原理

【原理】

CAS有三个操作数:
        V   内存值
        A   预期值(旧值)
        B   要修改的新值

当多个线程尝试使用CAS同时更新一个变量时:
	只有当 内存值V == 预期值A 时,才可以将 内存值V修改为 新值B
然后失败的线程不会挂起,而是被告知失败,可以 继续尝试(自旋) 或 不再重试!	

【自旋重试】

假设有两个线程:线程A和线程B,同时对内存值进行自增!内存值刚开始是0,旧值也是0。

1. 线程A先进入,此时 内存值0 == 旧值0,所以可以将内存值修改为新值1。
2. 线程A结束后,线程B进入,对内存值进行自增:
	但是 内存值1 != 旧值0,所以更新失败
    只能 将旧值0 更新为 内存值1,并告知下一次再试试!
3. 线程B自旋重试,此时 内存值1 == 旧值1,所以可以将内存值修改为新值2。

所以,哪怕没有加锁,也能实现线程安全。

【不再重试】

同样的,我们举例有两个线程:线程A和线程B,两个线程都要将内存值更新为10。

1. 线程A先进入,此时 内存值0 == 旧值0,所以可以将内存值修改为新值10。
2. 线程A结束后,线程B进入,要将内存值修改为10
	但是 内存值10 != 旧值0,所以更新失败
	只能 将旧值0 更新为 内存值10
3. 同时被告知线程B下次不用重试了。(因为我们的目的是将内存值更新为10,显然我们的目的已经完成了)

3、CAS 的使用 - 原子操作类

原子操作类,指的是java.util.concurrent.atomic包下,一系列以Atomic开头的包装类。

AtomicBooleanAtomicIntegerAtomicLong分别用于BooleanIntegerLong类型的原子性操作。

public class AtomicDemo {
    public static void main(String[] args) {
        // 原子操作类 AtomicInteger
        AtomicInteger atomicInteger = new AtomicInteger(2020);

        // 如果是期望值2020,更新为2021,否则就不更新
        System.out.println(atomicInteger.compareAndSet(2020, 2021)); // true
        System.out.println(atomicInteger.get()); // 2021

        System.out.println(atomicInteger.compareAndSet(2020, 2021)); // false
        System.out.println(atomicInteger.get()); // 2021
    }
}

Atomic操作的底层实现正是利用的CAS机制

public class AtomicInteger extends Number implements java.io.Serializable {
    public final int getAndIncrement() {
        return unsafe.getAndAddInt(this, valueOffset, 1);
    }
}
public final class Unsafe {
    public final int getAndAddInt(Object var1, long var2, int var4) {
        int var5;
        // 自旋操作
        do {
            var5 = this.getIntVolatile(var1, var2);
        } while(!this.compareAndSwapInt(var1, var2, var5, var5 + var4));

        return var5;
    }
    
    public final native boolean compareAndSwapInt(Object o, long offset, int expected, int x);
}

4、什么是 ABA问题 ?

ABA问题指的是,在CAS操作期间,共享变量的值从A变为B,然后再从B又变回A,导致进行CAS操作的线程可能会错误地认为共享变量的值没有发生变化,从而CAS操作成功。

  • 线程1取出共享变量的值A
  • 这时发生线程切换,共享变量的值被其他线程从A改为B,然后又从B改回A。
  • 然后线程1进行CAS操作,共享变量的值还是A,Compare成功,CAS成功。

虽然线程1的CAS操作成功了,但是线程1并不知道其他线程对共享变量的操作,这就是ABA问题。

5、ABA问题 - 举例

小明有余额100元,在提款机提取了50元,因为提款机问题,有两个线程,同时把余额从100变为50。

  1. 线程1(扣款):查询到余额为100,此时切换到其他线程
  2. 线程2(扣款):查询到余额为100,预期值100,执行扣款,余额变为50

此时,小红给小明汇款50元:

  1. 线程3(存款):查询到余额为50,执行存款,余额变为100
  2. 线程1(扣款):切换为线程1,预期值100,此时余额正好也是100,扣款成功,余额变为50

实际余额应该是 100-50+50=100元,但最终变成了 100-50+50-50=50元,这就是ABA问题带来的风险。

public class AbaProblemDemo {

    public static void main(String[] args) throws InterruptedException {
        // 账户余额100
        AtomicInteger account = new AtomicInteger(100);
        int startValue = account.get();
        System.out.println("账户初始余额:" + startValue);

        // 线程1:扣款50(最后执行)
        new Thread(() -> {
            try {
                TimeUnit.MILLISECONDS.sleep(200);
                account.compareAndSet(startValue, startValue - 50);
                System.out.println(Thread.currentThread().getName() + ":扣款50");
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
        }, "线程1").start();

        // 线程2:扣款50(首先执行)
        new Thread(() -> {
            account.compareAndSet(startValue, startValue - 50);
            System.out.println(Thread.currentThread().getName() + ":扣款50");
        }, "线程2").start();

        // 线程3:存款50(中间执行)
        new Thread(() -> {
            try {
                TimeUnit.MILLISECONDS.sleep(100);
                account.getAndUpdate(v -> v + 50);
                System.out.println(Thread.currentThread().getName() + ":存款50");
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
        }, "线程3").start();

        TimeUnit.MILLISECONDS.sleep(500);

        System.out.println("账户最终余额:" + account.get());
    }

}
账户初始余额:100
线程2:扣款50
线程3:存款50
线程1:扣款50
账户最终余额:50

6、ABA问题 - 解决方案

解决方案:Java中提供了两个原子类,为我们提供了版本号(时间戳)的方法解决了该问题!

  • 如果不关心引用变量更改了几次,只单纯的关心是否更改过,可以使用AtomicMarkableReference
  • 如果关心引用变量更改了几次,可以使用AtomicStampedReference

这里演示一下用AtomicStampedReference解决ABA问题:

public class AbaProblemSolveDemo {

    public static void main(String[] args) throws InterruptedException {
        // 账户余额100
        AtomicStampedReference<Integer> account = new AtomicStampedReference<>(100, 1);
        int expectedValue = account.getReference();
        int expectedStamp = account.getStamp();
        System.out.println("账户初始余额:" + expectedValue);

        // 线程1:扣款50(最后执行)
        new Thread(() -> {
            try {
                TimeUnit.MILLISECONDS.sleep(200);
                boolean result = account.compareAndSet(expectedValue, expectedValue - 50, expectedStamp, expectedStamp + 1);
                System.out.println(Thread.currentThread().getName() + ":扣款50,执行结果:" + result);
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
        }, "线程1").start();

        // 线程2:扣款50(首先执行)
        new Thread(() -> {
            boolean result = account.compareAndSet(expectedValue, expectedValue - 50, expectedStamp, expectedStamp + 1);
            System.out.println(Thread.currentThread().getName() + ":扣款50,执行结果:" + result);
        }, "线程2").start();

        // 线程3:存款50(中间执行)
        new Thread(() -> {
            try {
                TimeUnit.MILLISECONDS.sleep(100);
                account.set(account.getReference() + 50, account.getStamp() + 1);
                System.out.println(Thread.currentThread().getName() + ":存款50");
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
        }, "线程3").start();

        TimeUnit.MILLISECONDS.sleep(500);

        System.out.println("账户最终余额:" + account.getReference());
    }

}
账户初始余额:100
线程2:扣款50,执行结果:true
线程3:存款50
线程1:扣款50,执行结果:false
账户最终余额:100

7、CAS 小结

CAS(Compare and Swap)是一种在并发编程中常用的原子操作,具有一些优点和缺点。

优点:

  1. 原子性: CAS操作是原子性的,要么成功执行并更新变量的值,要么失败并不执行任何操作。这种原子性可以确保多线程环境下的数据一致性。
  2. 非阻塞: CAS操作是非阻塞的,它不会使线程进入阻塞状态,而是在操作失败时立即返回,让线程继续执行其他操作。这减少了线程的上下文切换和内核态与用户态的切换,提高了性能。
  3. 无锁: CAS操作是一种无锁的同步机制,它不需要使用锁来保护共享资源,因此避免了锁的竞争和开销,减少了死锁和线程阻塞的可能性。
  4. 实时性: CAS操作通常具有较好的实时性,因为它不需要等待其他线程的释放锁或者唤醒。

缺点:

  1. ABA问题: 在上面已经具体介绍了,并且给出了解决方案。
  2. 循环时间: CAS操作可能会出现自旋等待的情况,当操作失败时,线程会反复尝试CAS操作,直到操作成功或达到最大重试次数。如果CAS操作的竞争较激烈,循环时间过长会消耗CPU资源。
  3. 只能保证单个变量的原子性: CAS操作只能保证对单个变量的原子性操作,对于复合操作或多个变量之间的操作,需要额外的手段来保证原子性。

综上所述,CAS操作具有原子性、非阻塞和无锁等优点,但也存在ABA问题、自旋等待的缺点,因此在使用时需要综合考虑其优缺点,并根据具体场景选择合适的同步机制。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

scj1022

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值