JUC并发编程

112 阅读1小时+

JUC并发编程概述

JUC是指java.util.concurrent在并发编程中使用的工具包,主要包含以下三个:

  • java.util.concurrent:提供并发编程中常用的工具和框架,例如线程池、阻塞队列、同步器、并发集合等。
  • java.util.concurrent.atomic:提供原子操作类,用于在多线程并发编程中进行原子性操作。
  • java.util.concurrent.locks:提供更加灵活强大的锁机制,用于编写更加复杂的同步代码块。

线程基础知识

  • 1把锁:synchronized关键字

  • 2个并和1个串

    1. 并发:指同一个实体上的多个事件,是在一台机器上同时处理多个任务,同一时刻实际只有一个任务在执行。
    2. 并行:指不同实体上的多个事件,是在多台机器上同时处理多个任务,同一时刻多个任务同时在执行,各干各的。
    3. 串行:指同一个实体上的一个事件,是在一台机器上处理一个任务,每个任务必须等待前一个任务完成之后才能开始执行。
  • 3个程

    1. 进程:指系统中运行的一个应用程序,每个进程都有自己的内存空间和系统资源。
    2. 线程:同一个进程内会有1个或多个线程,是大多数操作系统进行时序调度的基本单元,线程在程序执行过程中可以独立执行特定的任务,线程也被称为轻量级进程。
    3. 管程:管程就是Monitor(锁),用于多线程编程的同步机制,用于解决多个线程访问共享资源时可能出现的竞争和并发问题。每个对象实例都会有一个Monitor对象,Monitor对象和Java对象一同创建并销毁,底层由C++实现。
  • 线程分类(默认都是用户线程)

    1. 用户线程:系统的工作线程,用来完成系统需要完成的业务操作。
    2. 守护线程:为其他线程服务的特殊线程,在后台完成一些系统性的任务。比如垃圾回收线程。

守护线程作为一个服务线程,对应的服务对象销毁了守护线程也会跟着销毁。如果所有的用户线程全部结束了,意味着程序需要完成的业务操作也就全部结束了,此时守护线程也会跟着结束,系统就会退出。

守护线程demo

/**
 * @author 文轩
 * @create 2024-03-12 13:02
 *
 * 用户线程demo
 */
public class DaemonDemo {
    public static void main(String[] args) {
        Thread t1 = new Thread(() -> {
            System.out.println(Thread.currentThread().getName() + "线程开始运行,该线程为" +
                    (Thread.currentThread().isDaemon() ? "守护线程" : "用户线程"));
            while (true) {
            }
        }, "t1");
        // 将线程设置为守护线程
        t1.setDaemon(true);
        t1.start();
        System.out.println("main线程结束");
    }
}

创建线程的方式

Thread

继承Thread类创建线程步骤:

  1. 编写一个继承Thread的线程任务类。
  2. 线程任务类重写run(),在run方法中实现线程需要实现的业务操作。
  3. 创建线程任务类实例,调用start()。

继承Thread类创建线程的优缺点:

  • 优点:编码简单。
  • 缺点:线程类必须继承Thread类,导致无法继承其他类进行扩展。

继承Thread类创建线程的编码实现:

/**
 * @author 文轩
 * @create 2024-03-12 13:13
 */
public class ThreadDemo {
    public static void main(String[] args) {
        MyThread myThread = new MyThread();
        myThread.start();
    }
}

class MyThread extends Thread {
    @Override
    public void run() {
        System.out.println(Thread.currentThread().getName() + "开始运行");
    }
}

Runnable

实现Runnable接口创建线程的步骤:

  1. 编写一个实现Runnable接口的线程任务类。
  2. 创建线程任务类实例,将线程任务类实例封装成Thread实例。
  3. 调用Thread实例的start()。

实现Runnable接口创建线程的优缺点

  • 缺点:相较于继承Thread类创建线程编码更加复杂。
  • 优点:
    1. 线程任务类实现Runnable接口,可以继续继承其他类。
    2. 同一个线程任务对象可以被包装成多个线程对象。
    3. 适合多个线程去共享同一个资源。

实现Runnable接口的编码实现:

/**
 * @author 文轩
 * @create 2024-03-12 13:20
 */
public class RunnableDemo {
    public static void main(String[] args) {
        RunnableImpl runnable = new RunnableImpl();
        new Thread(runnable, "t1").start();
    }
}

class RunnableImpl implements Runnable {
    @Override
    public void run() {
        System.out.println(Thread.currentThread().getName() + "线程开始运行");
    }
}

FutureTask

FutureTask创建线程的步骤:

  1. 创建一个实现Callable接口的线程任务类。
  2. 创建一个线程任务类和FatureTask类实例,并将线程任务类实例封装到FatureTask实例中。
  3. 创建一个Thread实例,并将FatureTask类实例封装到Thread实例中。
  4. 可以通过FatureTask实例的get()获取线程返回值。

FutureTask创建线程的优缺点:

  • 优点:在Runnable基础上,可以获取线程的返回值并且可以对线程进行异常处理。
  • 缺点:
    1. get()阻塞,一旦调用get()获取结果,那么线程就会阻塞等待结果,直到获取到返回结果才会继续往下执行。
    2. isDone()轮询,轮询的方式会消耗CPU资源,如果想要异步获取结果,通常会以轮询的方式获取。

FutureTask创建线程的代码示例:

/**
 * @author 文轩
 * @create 2024-03-13 16:07
 */
public class FutureTaskDemo {
    public static void main(String[] args) {
        FutureTask<String> futureTask = new FutureTask<>(new CallableImpl());
        new Thread(futureTask).start();
        // 获取线程返回值
        String result = null;
        try {
            result = futureTask.get();
        } catch (Exception e) {
            e.printStackTrace();
        }
        System.out.println("线程返回值:" + result);
    }
}

class CallableImpl implements Callable<String> {

    @Override
    public String call() throws Exception {
        System.out.println(Thread.currentThread().getName() + "线程开始运行");
        return "success";
    }
}

CompletableFuture

CompletableFuture介绍

CompletableFuture是Java8中新增的一个类,位于java.util.concurrent包中,用于实现异步编程。CompletableFuture提供了一种简单而强大的方式来处理异步操作的结果,可以在异步完成后触发回调函数、组合多个CompletableFuture等。

CompletableFuture实现了CompletionStage接口和Future接口,提供了丰富的方法来处理异步任务的结果,支持链式调用、组合、回调等操作。

  • CompletionStage:代表异步计算过程中的某一个阶段,一个阶段完成以后可能会触发另外一个阶段。一个阶段的执行可能是被单个阶段的完成触发,也可能是由多个阶段一起触发。
  • Future:异步任务接口,代表一个异步计算的结果,允许在异步任务执行完毕之前进行一些其他操作。

CompletableFuture的优点

  1. 非阻塞式编程:可以利用回调函数或组合操作,在任务执行完毕之后再进行相应的处理,避免线程阻塞,提高程序执行效率。
  2. 提高并发性能:CompletableFuture可以利用线程池执行异步任务,可以更好地利用系统资源,提高程序地并发性能。
  3. 异常处理:CompletableFuture提供了丰富地异常处理方法,可以方便地处理异步任务中可能发生的异常情况,保证程序的可靠性。
  4. 可组合性:CompletableFuture的方法可以方便的进行组合操作,串联多个异步任务,实现复杂的异步任务流程控制。

CompletableFuture异步方法

CompletableFuture提供了两个静态方法来进行异步处理,以及两个两个方法来处理异步任务完成之后的操作:

  • runAsync:接收一个Runnable对象作为参数,创建一个异步执行任务,不返回任务执行结果。
  • supplyAsync:接收一个Supplier对象作为参数,创建一个异步执行任务,并返回一个CompletableFuture对象,可以获取任务的执行结果。
  • whenComplete:接收一个BiConsumer对象作为参数,表示当CompletableFuture的计算结果完成时对结果进行处理。
  • exceptionally:接收一个Function对象作为参数,表示当CompletableFuture计算结果完成时,若出现异常则触发指定的异常处理操作,并返回一个新的CompletableFuture对象
/**
 * @author 文轩
 * @create 2024-03-13 23:34
 */
public class CompletableFutureDemo {
    public static void main(String[] args) {
        // 异步计算,没有返回值
        CompletableFuture<Void> completableFuture = CompletableFuture.runAsync(() -> {
            System.out.println(Thread.currentThread().getName());
        });

        // 异步计算,有返回值
        CompletableFuture<String> objectCompletableFuture = CompletableFuture.supplyAsync(() -> {
            System.out.println(Thread.currentThread().getName());
            return "supplyAsync";
        });
        try {
            System.out.println("结果:" + objectCompletableFuture.get());
        } catch (Exception e) {
            e.printStackTrace();
        }

        // 异步计算,并且回调通知
        CompletableFuture.supplyAsync(() -> {
            System.out.println(Thread.currentThread().getName());
            return "result";
        }).whenComplete((value, exception) -> {
            if(Objects.isNull(exception)) {
                System.out.println("异步回调通知:" + value);
            }
        }).exceptionally(e -> {
            System.out.println("出现异常:" + e.getMessage());
            return null;
        });
    }
}

CompletableFuture其他方法

  • get():获取异步任务的结果,如果异步任务还没有执行完则会阻塞等待异步任务执行完。
  • join():获取异步任务的结果,如果异步任务还没有执行完则会阻塞等待异步任务执行完。和get()区别在于调用join()不会产生编译时异常。
  • getNow():获取异步任务的结果,如果异步任务还没有执行完则返回默认值。
  • complete():如果异步任务还没有完成,则直接将异步任务设置为完成状态,并且返回值设置为参数值。
  • thenApply():异步任务串行化,异步任务继续往下走。当前步骤有异常的话就不会往下执行。
  • handle():异步任务串行化,异步任务继续往下走。当前步骤有异常还会继续执行下一步。

线程池创建线程

线程池:一个容纳多个线程的容器,容器中的线程可以重复使用,避免频繁创建和销毁线程。线程池的核心思想是线程复用,同一个线程可以被重复使用来处理多个任务。

线程池的作用:

  • 降低资源消耗,减少创建和销毁线程的次数,每个工作线程都可以被重复利用,可以执行多个任务
  • 提高响应速度,当任务到达时,如果有线程可以直接使用,不会出现系统僵死
  • 提高线程的管理性,使用线程池可以进行统一的分配,调优和监控

线程池的参数

  • corePoolSize:线程池中核心线程数的最大值
  • maximumPoolSize:线程池中线程数的最大值
  • keepAliveTime:非核心线程空闲超时时间
  • workQueue:存放任务的阻塞队列
  • handler:饱和策略,当最大线程池和阻塞队列都满了时的处理策略

线程池工作原理 线程池底层工作原理.png

线程的饱和策略:

  • 直接提交策略(Direct handoff):当线程池的任务队列已满且最大线程数已达到上限时,直接在主线程中执行任务,而不将任务放入队列中,这样可以避免任务的排队等待,但可能会导致主线程阻塞。
  • 无界队列策略(Unbounded queue):使用无界队列将任务保存起来。当线程池的任务队列已满且最大线程数已达到上限时,继续将任务放入队列中,直到计算机资源耗尽。
  • 有界队列策略(Bounded queue):使用有界队列来保存任务。当线程池的任务队列已满且最大线程数已达到上限时,新的任务会被拒绝执行,并触发拒绝策略。
  • 调用者运行策略(Caller runs):当线程池的任务队列已满且最大线程数已达到上限时,新的任务会由提交任务的线程来执行,而不会交由线程池的线程执行。这样可以保证提交任务的线程不会被阻塞,但可能会影响系统的响应性能。
  • 拒绝策略(Rejected):当线程池的任务队列已满且最大线程数已达到上限时,无法接受新的任务时,可以采用不同的拒绝策略来处理。常见的拒绝策略包括直接拒绝、抛出异常、丢弃最老的任务和调用者运行等。

线程相关方法

start

start():用来启动一个线程,当调用start()方法后,JVM会在新线程中调用run()方法。

只有调用start()方法才会启动一个新线程,并在线程中执行run()方法,如果直接调用run()方法,则仅会在当前线程中执行run(),不会启动新线程。

一个Thread对象只能调用一次start()方法,连续多次调用start()方法会导致IllegalThreadStateException异常。

sleep

Thread类提供静态的sleep()方法,用于让当前线程进入睡眠状态(暂停一段时间)。

  • sleep()方法通过抛出interruptedException来处理中断异常。其他线程可以通过调用interrupt()方法,来打断正在睡眠的线程。
  • sleep()方法不会释放锁,即使当前线程持有某个对象的锁,在调用sleep()方法后,线程仍然会继续持有锁。
  • sleep()方法会释放CPU资源,让线程暂停执行一段时间,睡眠结束后线程需要抢占锁。

yeild

Thread类提供静态的yield(),用于提示调度器当前线程愿意让出CPU,给其他等待线程更多的执行机会。

  • 调用yield()方法后,当前线程会放弃CPU资源,但不是绝对保证其他线程能立即获得CPU时间片,具体的实现依赖于操作系统的任务调度器。
  • yield()方法并不会释放锁,即使当前线程持有某个对象的锁,在调用yield()方法后,该线程仍然会继续持有这个锁。

join

join()方法用于让一个线程等待另一个线程执行完毕,调用join()方法的线程会暂时阻塞,直到被调用join()方法的线程执行完毕才会继续执行。

  • join()方法可以用于解决线程间的依赖关系,比如需要等待某个线程计算完毕后再进行下一步操作。
  • join()方法是被synchronized修饰的,本质上是一个对象锁,其内部的wait()方法调用也是释放锁的,但是释放的是当前的线程对象锁,而不是外面的锁。

setDaemon

setDaemon()方法用于将当前线程设置为守护线程

  • 当所有的非守护线程执行完毕后,不管守护线程是否执行完毕,程序都会自动退出。
  • 守护线程通常用于在后台提供一些服务或执行一些任务,比如垃圾回收、日志记录等。

interrupt

interrupt介绍

interrupt()方法用于中断线程的执行,当调用一个线程的interrupt()方法时,会向该线程发送一个中断信号,协商线程停止。

interrupt()方法的中断只是一种协商机制,该方法仅仅是将线程对象的中断标志设置为true,并不是真正的将线程中断,如果想要让线程中断,需要在执行逻辑中实现才行。

如果线程处于阻塞状态(例如:sleep、wait、join状态),别的线程中调用当前线程的interrupt方法,那么线程将立即退出被阻塞状态,同时当前线程的interrupt状态也将被清除(变成false),并且当前线程会抛出一个InterruptedException异常。

/**
 * @author 文轩
 * @create 2024-03-15 19:34
 */
public class SleepInterruptDemo {
    public static void main(String[] args) {
        Thread t1 = new Thread(() -> {
            try {
                TimeUnit.SECONDS.sleep(2);
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
            // 下面代码不会执行,上面中断过程中会抛出InterruptedException异常。
            System.out.println(Thread.currentThread().getName() + "执行完毕");
        }, "t1");
        t1.start();

        try {
            TimeUnit.SECONDS.sleep(1);
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }

        t1.interrupt();
        System.out.println("main线程执行完毕");
    }
}

中断相关的三个方法:

  • public void interrupt():实例方法,设置线程的中断状态为true,发起一个协商不会立刻停止线程。
  • public boolean isInterrupted():实例方法,判断当前线程是否被中断(通过检查中断标志位)。
  • public static boolean interrupted():静态方法,返回当前线程的中断状态(通过中断标志位判断),同时将当前线程的中断状态设置为false,清除线程的中断状态。

实现线程中断的方式

  1. 通过volatile变量实现线程中断
/**
 * @author 文轩
 * @create 2024-03-15 19:14
 *
 * 通过volatile变量实现线程中断
 */
public class VolatileInterruptDemo {

    private static boolean flag = true;

    public static void main(String[] args) {
        new Thread(() -> {
            while (flag) {
                System.out.println("线程正在运行...");
            }
            System.out.println("线程停止运行...");
        }, "t1").start();

        try {
            TimeUnit.SECONDS.sleep(1);
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }

        new Thread(() -> {
            flag = false;
        }, "t2").start();
    }
}
  1. 通过AtomicBoolean原子布尔类实现线程中断
/**
 * @author 文轩
 * @create 2024-03-15 19:19
 *
 *
 */
public class AtomicBooleanInterruptDemo {

    private static AtomicBoolean atomicBoolean = new AtomicBoolean(true);

    public static void main(String[] args) {
        new Thread(() -> {
            while (atomicBoolean.get()) {
                System.out.println("线程正在运行...");
            }
            System.out.println("线程结束运行...");
        }, "t1").start();

        try {
            TimeUnit.SECONDS.sleep(1);
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }

        new Thread(() -> {
            atomicBoolean.set(false);
        }, "t2").start();
    }
}
  1. 通过interrupt相关方法
/**
 * @author 文轩
 * @create 2024-03-15 19:22
 *
 * 通过interrupt相关方法实现线程中断
 */
public class InterruptDemo {

    public static void main(String[] args) {
        Thread t1 = new Thread(() -> {
            while (!Thread.currentThread().isInterrupted()) {
                System.out.println("线程正在运行...");
            }
            System.out.println("线程结束运行...");
        }, "t1");
        t1.start();

        try {
            TimeUnit.SECONDS.sleep(1);
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }

        new Thread(() -> {
            t1.interrupt();
        }, "t2").start();
    }
}

Java对象内存布局

Java对象内存布局

在HotSpot虚拟机中,对象在堆内存的存储布局可以划分为三个部分:对象头、实际数据和对齐填充。

  • 对象头:对象实例在内存中的开头部分,用于存储对象的元数据信息。
  • 实际数据:存储类的属性数据信息,包括父类的属性信息。
  • 对齐填充:虚拟机要求对象起始地址必须是8字节的整数倍,填充数据不是必须存在的,仅仅是为了字节对齐,这部分内存按8字节补充对齐。

Java对象内存布局.png

对象头的详细信息:

JUC-对象头的详细信息.png

Java对象的对象头

Java对象的对象头由两部分组成:

  • 对象标记(Mark World):存储对象的元数据信息和状态标记。
  • 类指针(Class Pointer):指向对象所属类的元信息,虚拟机通过这个指针来确定这个对象是哪个类的实例。

在64位系统中,Mark World占用8个字节,Class Point占8个字节,一共16个字节。

Java对象的对象头.png

Java对象的压缩指针

Java对象的压缩指针:JVM的一种优化技术,旨在减少对象指针在内存中所占的空间,从而节省内存并提高性能。

  • 默认JVM开启压缩指针,一个Object对象占用16字节 = 8个字节(对象标记) + 4个字节(类指针) + 4个字节(对象填充)。
  • 手动关闭JVM压缩指针,一个Object对象占用16字节 = 8个字节(对象标记) + 8个字节(类指针)。

Java -XX:+PrintCommandLineFlags -version:查看当前虚拟机信息 手动关闭压缩指针,添加JVM参数:-XX:-UseCompressedClassPointers

syschronized关键字

synchronized关键字介绍

Java中的synahronized关键字用于实现多线程同步,可以修饰方法或代码块,保证在同一时刻只有一个线程可以访问被synchronized修饰的方法或代码块。

  • 对于普通同步方法,锁是当前实例对象,通常指this,同一个对象的所有的普通同步方法用的是同一把锁。
  • 对于静态同步方法,锁是当前类的Class对象,所有对象的所有静态同步方法用的是同一把锁。
  • 对于同步方法块,锁是synchronized括号内的对象,对于使用同一个对象的同步方法块用的是同一把锁。

从字节码文件分析synchronized

将class字节码文件反编译:javap -cv xxx.class

  1. 同步代码块
public class SynchronizedDemo {

    private Object object = new Object();

    public void method1() {
        synchronized (object) {
            System.out.println("同步代码块");
        }
    }
}

image.png

  1. 普通同步方法
public class SynchronizedDemo {
    public synchronized void method2() {
        System.out.println("普通同步方法");
    }
}

image.png

  1. 静态同步方法
public class SynchronizedDemo {
    public static synchronized void method3() {
        System.out.println("静态同步方法");
    }
}

image.png

多线程锁的底层原理

每个Java对象都可以关联一个Monitor对象(ObjectMonitor.java),Monitor实例存储在堆中,如果使用synchronized关键字给对象上锁之后,对象头的Mark Word中就被设置指向Monitor对象的指针(重量级锁)。

ObjectMonitor.java是Java虚拟机(HotSpot)实现的,底层调用ObjectMonitor.cpp文件,也就是C++实现。

Java多线程锁底层原理.png

底层工作流程:

  1. 开始时obj对象的Monitor中Owner为null。
  2. 当t1线程执行synchronized(obj)就会将Monitor对象的所有者Owner置为t1(t1线程获取到该锁),obj对象的MarkWord指向该Monitor对象(obj对象和这个Monitor进行关联)。
  3. 在t1线程上锁的过程,t2、t3线程如果也执行synchronized(oibj),就会进入EntryList BLOCKED(阻塞队列)。
  4. t1线程执行同步代码块的内容,根据obj对象头中Monitor地址寻找,设置Owner为空,把线程栈的锁记录中的对象头的值设置回MarkWord。
  5. 唤醒EntryList中等待的线程来竞争锁,竞争是非公平的,某个新的线程获取到锁(可能是t2线程,也可能是t3线程),比如t2线程获取到锁,阻塞队列中其他线程(t3线程)继续阻塞。
/**
 * @author 文轩
 * @create 2024-03-15 11:34
 */
public class LockPrincipleDemo {

    private static Object obj = new Object();

    public static void method() {
        synchronized (obj) {
            System.out.println("执行业务逻辑");
        }
    }

    public static void main(String[] args) {
        new Thread(() -> {
            method();
        }, "t1").start();

        new Thread(() -> {
            method();
        }, "t2").start();

        new Thread(() -> {
            method();
        }, "t3").start();
    }
}

锁升级过程

为什么要有锁升级的过程?

  • Java5之前,锁状态只有无锁和重量级锁,重量级锁的获取需要在用户态和内核态之间进行切换,这个切换过程非常消耗系统资源,所以在Java6之后引入了偏向锁和轻量级锁,避免加锁直接就变成重量级锁,来提升系统性能。

锁升级过程:

// 从左到右,性能越差,越能保证线程并发数据的安全性
无锁  --->  偏向锁   --->  轻量级锁   --->   重量级锁
  1. 无锁:指没有对资源进行锁定,所有线程都能访问并修改同一个资源,但同时只有一个线程修改成功。
  2. 偏向锁:当只有一个线程执行同步代码块的话,锁对象升级成偏向锁。执行同步代码块后,线程并不会主动释放偏向锁,当第二次到达同步代码块时,线程会判断此时持有锁的线程是否为自己,如果是则正常往下执行。当一段同步代码一直被同一个线程所访问时,即不存在多个线程的竞争时,那么该线程再后续访问时便会自动获得锁,不需要进行系统调用可以提高系统性能。
  3. 轻量锁:当存在多个线程同时访问偏向锁时,偏向锁就会升级为轻量级锁,其他线程会通过自旋的方式获取锁,线程不会阻塞从而提高性能。获取锁的操作就是通过CAS修改对象头里的锁标志位,如果标志位为释放状态则线程可以直接获取锁,如果标志位为锁定状态则需要通过自旋不断尝试获取锁。
  4. 重量级锁:当线程自旋次数到达一个限制值(虚拟机参数可以设置,默认值为10)后,轻量锁会升级为重量级锁。锁竞争时其他线程会被挂起,等待将来被唤醒。线程的挂起和唤醒,会导致系统调用(挂起和唤醒需要切换到内核态),从而消耗大量的系统资源。

四种锁对象头的对象标记64位分别表示不同含义:

image.png

无锁

无锁:初始状态,一个对象被实例化后,如果没有被任何线程竞争锁,那么对象的对象头的对象标记就为无锁状态(001)。

偏向锁

偏向锁:单线程访问同步代码,通过修改对象的对象标记中偏向线程ID、偏向模式。如果不存在其他线程竞争,那么持有偏向锁的线程将永远不需要进行同步。

偏向锁的获取锁流程:

  • 当锁对象第一次被线程获得的时候进入偏向状态,标记为101,同时使用CAS操作将线程ID记录到Mark Word。
  • 如果CAS操作成功,这个线程以后进入这个锁相关的同步块,查看这个线程ID是自己的就表示没有竞争,就不需要再进行同步操作。
  • 当有另一个线程去尝试获取这个锁对象时,偏向状态就宣告失败,此时撤销偏向后恢复到未锁定或轻量级锁状态。

偏向锁的撤销:

  • 线程正在执行同步代码时,其他线程来抢夺,该偏向锁会进行锁升级,升级成重量级锁,此时轻量级锁由原来持有偏向锁的线程持有,继续执行同步代码,而正在竞争的线程会进入自旋等待获得轻量级锁。
  • 线程执行同步代码的过程中没有其他线程来抢夺,则线程执行完之后将对象头设置为无锁状态,并撤销偏向锁,重新偏向。

JVM对偏向锁的设置:

  • JVM默认开启偏向锁,但是不会在程序启动时立即生效,而是延迟4000毫秒后才生效。
  • 开启偏向锁的VM参数:-XX:+UseBiasedLocking
  • 设置偏向锁延迟开启的VM参数:-XX:BiasedLockingStartupDelay=0

JDK15之后正在逐步废弃偏向锁,需要手动开启偏向锁。

轻量级锁

JVM会为每个线程在当前线程的栈帧中创建用于存储锁记录的空间(Displaced Mark Word),若一个线程获得锁时发现是轻量级锁,会把锁的MarkWord复制到自己的Displaced Mark Word里面,然后线程尝试用CAS将锁的MarkWord替换为指向锁记录的指针。如果成功,当前线程获得锁,如果失败,表示Mark Word已经替换成了其他线程的锁记录,说明在与其他线程竞争锁,当前线程尝试使用自旋来获得锁。

轻量级锁当自旋一定程度或者次数之后,会升级为重量级锁:

  • 线程如果自旋成功了,那下次自旋最大次数会增加。
  • 线程如果很少自旋成功,那下次会减少自旋最大次数。

自旋锁获取锁的过程:

  1. 创建锁记录对象,每个线程的栈帧都会包含一个锁记录的结构,存储锁定对象的Mark Word。

  2. 让锁记录中的Object reference指向锁住的对象,并尝试用CAS替换Object的Mark Word,将Mark Word的值存入锁记录。

  3. 如果CAS替换成功,对象头中存储了锁记录地址和状态00,表示该线程给对象加锁。

  4. 如果CAS替换失败,有两种情况:

    • 如果是其他线程已经持有了该Object的轻量级锁,这时表明有竞争,进入锁膨胀过程。
    • 如果是线程自己执行了synchronized锁重入,就添加一条Lock Record作为重入的计数。
  5. 当同步代码执行完毕后,进行解锁有两种情况:

    • 如果有取值为null的锁记录,表示有重入,这时重置锁记录,表示重入计数减1。
    • 如果锁记录的值不为null,这时使用CAS将Mark Word的值恢复给对象头。如果成功,则解锁成功;如果失败,说明轻量级锁进行了锁膨胀或已经升级为重量级锁,进入重量级锁解锁流程。

锁膨胀:在尝试加轻量级锁的过程中,CAS操作无法成功,可能是其他线程为此对象加上了轻量级锁,这时需要进行锁膨胀,将轻量级锁升级为重量级锁。

重量级锁

重量级锁:基于进入和退出Monitor对象实现的,在编译时会将同步块的开始位置插入monitor enter指令,在结束位置插入monitor exit指令。

当线程执行到monitor enter指令时,会尝试获取对象所对应的Monitor所有权,如果获取成功,则表示成功获取锁,会在Monitor的owner中存放当前线程的ID,这样它将处于锁定状态,除非退出同步块,否则其他线程无法获取到这个Monitor。

锁升级总结

优点缺点适用场景
偏向锁加锁和解锁不需要额外的消耗,和执行非同步方法相比仅存在纳秒级的差距如果线程间存在锁竞争,会带来额外的锁撤销的消耗适用于只有一个线程访问同步块的场景
轻量级锁竞争的线程不会阻塞,提高了程序的相应速度如果始终得不到锁竞争的线程,会自旋消耗CPU追求响应时间,同步块执行速度非常块
重量级锁线程竞争不使用自旋,不会消耗CPU线程阻塞,响应时间缓慢追求吞吐量,同步块执行速度较长

JIT对锁的优化

JIT:(Just In Time)运行时动态编译计数,通常用于提高程序性能,JIT编译器是JVM的一部分,将字节码即时编译成本地机器码,以提高程序的执行效率。

锁消除

锁消除:指对于检测出不可能存在锁竞争的共享数据的锁进行消除,通过逃逸分析来支持,如果堆上的共享数据不可能逃逸出去被其他线程访问到,那么就可以把它们当作私有数据对待,也就可以将它们的锁进行消除。

/**
 * @author 文轩
 * @create 2024-03-23 21:09
 *
 * 锁消除
 */
public class LockEliminationDemo {

    public void method() {
        Object o = new Object();
        // 这段同步代码块,通过o对象进行加锁,而不同线程调用method方法使用的都是不同的锁对象,
        // 不可能存在多线程竞争情况,所以JIT会将下面锁进行锁消除
        synchronized (o) {
            System.out.println("执行业务逻辑");
        }
    }
}

锁粗化

锁粗化:虚拟机探测到一串的操作都对同一个对象加锁,将会把锁的范围扩展(粗化)到整个操作序列的外部。

/**
 * @author 文轩
 * @create 2024-03-23 21:12
 *
 * 锁粗化
 */
public class LockCoarseningDemo {
    public static Object objectLock = new Object();

    public static void method() {
        // 下面三个加锁操作,使用的是同一把锁,JIT会自动将下面代码优化成功一个锁操作。
        synchronized (objectLock) {
            System.out.println("业务操作1");
        }
        synchronized (objectLock) {
            System.out.println("业务操作2");
        }
        synchronized (objectLock) {
            System.out.println("业务操作3");
        }
    }
}

多线程锁

JUC多线程锁是用于实现线程同步和互斥的机制,常用的锁有synchronized关键字、ReentrantLock类、ReadWriteLock接口等。这些锁能够确保在同一时间只有一个线程能够访问被锁定的资源其他线程需要等待锁释放后才能访问。

悲观锁和乐观锁

悲观锁和乐观锁是两种并发控制的思想,用于解决多线程环境下的数据一致性和并发冲突的问题。

  • 悲观锁:悲观锁对共享资源持有悲观态度,认为自己访问期间会产生并发冲突,因此访问共享资源前先获取锁,保证自己独占资源。如Java中的synchronized关键字和ReentrantLock类。悲观锁在获取锁期间,其他线程无法读取、写入或修改共享资源,只能等待持有锁的线程释放锁。悲观锁可以确保同一时刻只有一个线程能够访问共享资源,保证数据的一致性,但是并发性能会受到影响。
  • 乐观锁:乐观锁对共享资源持有乐观态度,认为在自己访问期间不会产生并发冲突,不会主动加锁,而是在共新共享资源时进行验证。乐观锁的典型实现时通过版本号或时间戳等机制实现的,如果Java中的Atomic类、CAS操作、MVCC等。乐观锁在读取共享资源时不会对其加锁,多个线程可以同时读取资源,提高并发性能。但在更新资源时,会检查资源是否被其他线程修改过,如果被修改过,则认为存在并发冲突,需要进行回滚或重试。

公平锁和非公平锁

公平锁和非公平锁是两种线程争抢锁的策略,主要区别在于获取锁的顺序:

  • 公平锁:按照线程请求锁的顺序来分配锁,先来先得。当一个线程释放锁后,等待时间最长的线程将获得锁所得使用权。公平锁保证了每个线程的公平性,避免了饥饿现象,但是因为需要频繁的线程切换的开销,所以效率更低。
  • 非公平锁:线程允许插队,线程可以在未持有锁的情况下直接获取锁,不考虑其他线程的等待情况。当一个线程释放锁后,下一次获取锁的线程不一定是等待时间最长的线程。非公平锁提高了吞吐量,减少了线程切换的开销,但是可能会导致某些线程饿死的情况(线程无法获取锁)。
/**
 * @author 文轩
 * @create 2024-03-15 11:48
 *
 * 公平锁和非公平锁
 */
public class FairLockDemo {
    public static void main(String[] args) {
        // 获取非公平锁
        ReentrantLock nonFairLock = new ReentrantLock();
        ReentrantLock nonFairLock = new ReentrantLock(false);

        // 获取公平锁
        ReentrantLock fairLock = new ReentrantLock(true);
    }
}

可重入锁ReentrantLock

  • 可重入锁:指同一个线程在获取锁之后可以再次获取该锁而不会发生死锁的现象。可重入锁的实现依靠了一个计数器,当一个线程第一次获取锁时,计数器值加1,当同一个线程再次获取锁时,计数器再次加1;相应地,在每次解锁时,计数器值减1,直到计数器值为0时,锁被完全释放。ReentrantLock和synchronized都是可重入锁,可重入锁提供了更高的灵活性和可控性。
/**
 * @author 文轩
 * @create 2024-03-15 12:01
 *
 * 可重入锁
 */
public class ReentrantLockDemo {
    public static void main(String[] args) {
        ReentrantLock lock = new ReentrantLock();

        new Thread(() -> {
            // 计数器+1
            lock.lock();
            try {
                System.out.println(Thread.currentThread().getName() + "获取到锁");
                // 计数器+1
                lock.lock();
                try {
                    System.out.println(Thread.currentThread().getName() + "再次获取到锁");
                } finally {
                    // 计数器-1
                    lock.unlock();
                }
            } finally {
                // 计数器-1
                lock.unlock();
            }
        }).start();
    }
}

读写锁ReentrantReadWriteLock

读写锁:支持读写分离的锁机制,包括读锁和写锁。读锁可以被多个线程同时持有,但写锁只能被一个线程持有,同时读写锁不能同时被持有,所以读写锁非常适用于读多写少的场景。

读读可以共存、读写不能共存、写写不能共存。

读写锁的锁饥饿:指在使用读写锁过程中,某一种操作(读操作或写操作)长时间无法获取锁,导致一直处于等待状态。

读写锁的锁降级:指将写锁降级为读锁的操作,同一个线程下在获取到写锁的情况下还能同时获得读锁,当写锁释放之后就会降级成读锁。但同一个线程获得读锁后不能获取写锁,只能等读锁释放后才能获得写锁。

锁降级使用场景:当需要写后立刻读,可以保证写锁释放后其他线程无法获取写锁对数据进行修改,可以保证写后读到的数据没有发生改变。

读写锁的锁降级.png

/**
 * @author 文轩
 * @create 2024-03-24 14:17
 *
 * 读写锁
 */
public class ReadWriteLockDemo {

    private static ReadWriteLock lock = new ReentrantReadWriteLock();

    public static void read() {
        lock.readLock().lock();
        System.out.println("读操作");
        lock.readLock().unlock();
    }

    public static void write() {
        lock.writeLock().lock();
        System.out.println("写操作");
        lock.writeLock().unlock();
    }
}

邮戳锁StampedLock

StampedLock邮戳锁:Java8引入的锁机制,提供了乐观读锁,悲观读锁和悲观写锁的组合锁,可以避免读写锁的锁饥饿问题。

StampedLock的缺点:

  • StampedLock不支持重入,当获取StampedLock锁后,再次获取StampedLock会导致死锁。
  • Stampedlock不支持条件变量,没有提供类似于ReentrantLock种的Condition条件等待的功能。
  • 在使用StampedLock的过程不支持中断操作(interrupt),可能会出现程序不稳定。

StampedLock的三种模式:

  1. 写模式:独占写锁,用于写操作,写锁会阻塞其他所有的读操作和写操作。
  2. 读模式:共享读锁,用于读操作,读锁不会阻塞其他读操作,但会阻塞写操作。
  3. 乐观读模式:通过tryOptimisticRead()尝试获取乐观读锁,不会阻塞其他读写操作,但在实际读操作前需要验证数据是否仍然处于一致的状态。
/**
 * @author 文轩
 * @create 2024-03-24 15:02
 *
 * 邮戳锁
 */
public class StampedLockDemo {
    private static StampedLock stampedLock = new StampedLock();

    // 悲观写
    public static void write() {
        long stamp = stampedLock.writeLock();
        System.out.println("写操作");
        stampedLock.unlockWrite(stamp);
    }

    // 悲观读
    public static void read() {
        long stamp = stampedLock.readLock();
        System.out.println("读操作");
        stampedLock.unlockRead(stamp);
    }

    // 乐观读操作
    public static void optimisticRead() {
        long stamp = stampedLock.tryOptimisticRead();
        System.out.println("乐观读操作");
        if(!stampedLock.validate(stamp)) {
            // 说明存在其他线程进行了写操作
            System.out.println("乐观读过程种存在其他线程写操作");
            // 通过悲观读操作读取数据
            read();
        }
    }
}

多线程死锁及排查

死锁是指两个或两个线程在执行过程中,因抢夺资源而导致造成的一种互相等待的现象,若无外力干涉,则它们无法再继续推进下去。

Java死锁产生的四个必要条件:

  1. 互斥条件:当资源被一个线程占有时,别的线程线程不能使用。
  2. 不可剥夺条件:资源请求者不能强制从资源占有者手中夺取资源,资源只能由资源占有者主动释放。
  3. 请求和保持条件:当资源请求者在请求其他的资源的同时保持对原有资源的占有。
  4. 循环等待条件:存在一个等待循环队列,t1要t2的资源,t2要t1的资源,形成一个等待环路。

以上四个条件都成立的时候,便形成死锁,死锁情况下打破上述任何一个条件便可让死锁消失。

死锁的代码案例:

排查死锁:

  • 通过java命令
    1. jps -l:打印所有的java进程信息
    2. jstack 进程编号:打印Java进程的栈信息

image.png

  • 通过图形化:win + R,输入jconsole

image.png

多线程同步机制

Java多线程同步是指在多个线程访问共享资源时保证数据的一致性和正确性。

实现多线程同步的方式:

  1. Object中的wait()方法让线程等待,使用Object中的notify()方法唤醒线程。
  2. JUC包中的Condition的await()方法让线程等待,使用signal()方法唤醒线程。
  3. LockSupport类可以阻塞当前线程已经唤醒指定被阻塞的线程。

Object的wait和notify实现线程同步

Object的API:

  • public final void notify():唤醒正在等待监视器的单个线程。
  • public final void notifyAll():唤醒正在等待监视器的所有线程。
  • public final void wait():导致当前线程等待,直到另一个线程调用该对象的notify()方法或notifyAll()方法。
  • public final void wait(long timeout):限时等待,到n毫秒后结束等待或被唤醒。

Object的wait()和notify()的注意事项:

  1. Object的wait()方法和notify()方法必须在同步代码块或同步方法中使用,而且锁对象和Object对象必须是同一个。
  2. notify()必须在wait()方法之后调用,才能起到唤醒线程的效果,也就是说一个线程得先调用wait()方法之后,另一个线程再调用notify()方法才能唤醒等待的线程。

为什么wait()和notify()必须放在同步代码块或同步方法中执行?

wait()和notify()方法依赖于对象的监视器(monitor),只有在拥有对象的监视器(对象锁)时才能调用这两个方法。

/**
 * @author 文轩
 * @create 2024-03-16 10:19
 *
 * Object的wait和notify方法实现同步
 */
public class WaitNotifyDemo {
    public static void main(String[] args) {
        Object obj = new Object();

        new Thread(() -> {
            synchronized (obj) {
                System.out.println(Thread.currentThread().getName() + "开始运行");
                try {
                    obj.wait();
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
                System.out.println(Thread.currentThread().getName() + "被唤醒");
            }
        }, "t1").start();

        new Thread(() -> {
            synchronized (obj) {
                try {
                    TimeUnit.SECONDS.sleep(1);
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
                System.out.println(Thread.currentThread().getName() + "通知其他线程");
                obj.notify();
            }
        }, "t2").start();
    }
}

Condition的await和signal实现线程同步

Condition接口的await()和signal()方法可以配合ReentrantLock实现线程之间的协调和线程同步。

Condition接口的await()和signal()的注意事项:

  1. Condition接口的await()和signal()方法在执行之前需要先获取锁(比如ReentrantLock)。
  2. signal()必须在await()方法之后调用,才能起到唤醒线程的效果,也就是说一个线程得先调用await()方法之后,另一个线程再调用signal()方法才能唤醒等待的线程。

为什么Condition的await()和signal()方法在执行之前必须先获取锁?

因为Condition依赖于ReentrantLock来实现线程之间的协调和同步,Condition接口和ReentrantLock是密切关联的。

/**
 * @author 文轩
 * @create 2024-03-16 10:59
 *
 * 通过Condition接口的await()和signal()方法,实现线程间的等待和唤醒操作。
 */
public class AwaitSignalDemo {

    public static void main(String[] args) {
        ReentrantLock lock = new ReentrantLock();
        Condition condition = lock.newCondition();

        new Thread(() -> {
            lock.lock();
            System.out.println(Thread.currentThread().getName() + "进入阻塞");
            try {
                condition.await();
                System.out.println(Thread.currentThread().getName() + "被唤醒");
            } catch (InterruptedException e) {
                e.printStackTrace();
            } finally {
                lock.unlock();
            }
        }, "t1").start();

        try {
            TimeUnit.SECONDS.sleep(1);
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }

        new Thread(() -> {
            lock.lock();
            try {
                System.out.println(Thread.currentThread().getName() + "通知其他线程");
                condition.signal();
            } finally {
                lock.unlock();
            }
        }, "t2").start();
    }
}

LockSupport的park和unpark实现线程同步

LockSupport用于创建锁和其他同步类的基本线程阻塞原语,LockSupport类使用了一种名为Permit(许可)的概念来做到阻塞和唤醒线程的功能,每个线程都有一个许可,许可证只能有一个或零个,累加上限是1。LockSupport底层是通过调用Unsafe类的方法。

LockSupport类的API:

  • LockSupport.park():暂停当前线程,挂起原语。
  • LockSupport.unpark(Thread thread):恢复某个线程的运行。

为什么LockSupport可以突破wait/notify的原有调用顺序?

  • 因为LockSupport使用的是Permit许可证概念,即使先调用unpark方法也能获取到Permit,后调用park()方法因为有了Permit,所以不会被阻塞。 为什么唤醒两次后阻塞两次,但最终结果还是会阻塞线程?
  • 因为每个线程最多只能有一个Permit,连续调用两次unpark()和调用一次unpark()效果一样,只有一个Permit。而调用两次park()却需要消耗两个凭证,Permit不够所以会被阻塞。除非unpark()方法和park()方法是间隔调用的,那么Permit可以抵消掉,就不会被阻塞。
/**
 * @author 文轩
 * @create 2024-03-16 11:23
 *
 * 通过LockSupport的park和unpark方法实现线程同步
 */
public class ParkUnparkDemo {
    public static void main(String[] args) {
        Thread t1 = new Thread(() -> {
            System.out.println(Thread.currentThread().getName() + "进入阻塞");
            LockSupport.park();
            System.out.println(Thread.currentThread().getName() + "被唤醒");
        }, "t1");
        t1.start();

        new Thread(() -> {
            try {
                TimeUnit.SECONDS.sleep(1);
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
            System.out.println(Thread.currentThread().getName() + "通知" + t1.getName());
            LockSupport.unpark(t1);
        }, "t2").start();
    }
}

Java内存模型JMM

计算机硬件存储结构

CPU处理器速度远远大于主内存的,为了解决速度差异,在CPU处理器和主内存之间设置了多级缓存,这些缓存离CPU越近就越快,将频繁操作的数据缓存到这里,加快访问速度。

在计算机系统中,CPU高速缓存(简称缓存)是用于减少处理器访问内存所需平均时间的部件,在存储体系中位于自顶向下的第二层,仅次于CPU寄存器。

JMM-CPU缓存结构.png

CPU的运行并不是直接操作内存而是先把内存里面的数据读到缓存,而内存的读写操作会造成数据不一致的问题,JVM规范中定义一种Java内存模型来屏蔽掉各种硬件和操作系统的内存访问差异,以实现让Java程序在各种平台下都能达到一致性的内存访问效果。

JUC-缓存一致性.png

Java内存模型JMM介绍

Java内存模型是Java Memory Model(JMM),本身是一种抽象的概念,实际上并不存在,描述的是一组规则或规范,通过这组规范定义了程序中各个变量的访问方式,并决定一个线程对共享变量的写入和如何变成对另一个线程可见。

JMM的作用:

  • 屏蔽各种硬件和操作系统的内存访问差异,实现让Java程序在各种平台下都能达到一致的内存访问效果。
  • 通过JMM规定线程和内存之间的抽象关系。

Java内存模型的内存交互过程

  1. 每个线程都有自己的工作内存(本地内存),线程自己的工作内存中保存了线程使用到的变量的主内存副本拷贝。
  2. 线程对变量的所有操作(读取、赋值等)都必须在线程自己的工作内存中进行,而不能直接写入主内存中的变量。
  3. 线程修改变量值时先将工作内存中变量的拷贝值修改,然后再将值覆盖掉主内存中的变量,然后通知其他线程将对应的工作内存中的变量拷贝值修改。

Java内存模型的内存交互过程.png

Java内存模型的三大特性

可见性

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

可见性实现原理:线程修改变量值时先将工作内存中变量的拷贝值修改,然后再将值覆盖掉主内存中的变量,然后通知其他线程将对应的工作内存中的变量拷贝值修改。

/**
 * @author 文轩
 * @create 2024-03-16 13:23
 *
 * JMM之可见性
 */
public class VisibilityDemo {
    private static int number = 0;

    public static void main(String[] args) throws InterruptedException {
        new Thread(() -> {
            System.out.println("number = " + number);   // 0
            try {
                TimeUnit.SECONDS.sleep(2);
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
            System.out.println("number = " + number);   // 2
        }, "t1").start();

        TimeUnit.SECONDS.sleep(1);
        number = 2;
    }
}

原子性

原子性:某些线程正在执行某些具体业务操作时,中间不可以被分割,需要具体完成,要么同时成功,要么同时失败,保证指令不会受到线程上下文切换的影响。

有序性

有序性:指程序的运行顺序按照开发者的编写代码逻辑进行,而不受指令重排序的影响。

指令重排序:在执行程序时一条语句是由多条指令组成,指令在执行过程中可能会对这些指令进行重新排序以提高执行效率。指令重排序的前提是在保持程序运行结果不变的前提下,尽量减少吹起的空闲等待时间,提高整体性能。

处理器在进行指令重排序时,必须考虑指令之间的数据依赖性。

指令重排序一般分为以下几种:

源代码 -> 编译器优化的重排 -> 指令并行的重排 -> 内存系统的重排 -> 最终执行指令

JMM规范下多线程先行发生原则之happens-before

JMM规范下多线程先行发生原则之happens-before可以保证在不违反先行发生原则的前提下对代码优化,进行指令重排序。也就是说JMM规范关于多线程先行发生原则可以保证即使存在指令重排序,指令执行完之后的结果和没有发生指令重排序执行的结果相同。

happens-before总原则:

  • 如果一个操作happens-before另一个操作,那么第一个操作的执行结果将对第二个操作可见,而且第一个操作的执行顺序排在第二个操作之前。
  • 如果两个操作之间存在happens-before关系,并不意味着一定要按照happens-before原则指定的顺序来执行,只要重排之后的执行结果与按照happens-before关系来执行的结果一致,那么系统会进行指令重排序进行代码优化。

happens-before之八条原则:

  1. 次序规则:一个线程内,按照代码的顺序,写在前面的操作先行发生于写在后面的操作。
  2. 锁定规则:一个unLock操作先行发生于后面对同一个锁的lock操作。
  3. volatile变量规则:对一个volatile变量的写操作先行发生于后面对这个变量的读操作,前面的写对后面的读是可见的。
  4. 传递规则:如果操作A先行发生于操作B,而操作B又先行发生于操作C,则可以得出操作A先行发生于操作C。
  5. 线程启动规则:Thread对象的start()方法先行发生于此线程的每一个动作。
  6. 线程中断规则:对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生。
  7. 线程终止规则:线程中的所有操作都优先发生于对此线程的终止检测。
  8. 对象终结规则:一个对象的初始化完成(构造函数执行结束)先行发生于它的finalize()方法的开始。

JMM与volatile关键字的关系

volatile变量特点

volatile关键字是Java虚拟机提供的轻量级的同步机制:

  • 保证可见性
  • 保证有序性(禁止指令重排)
  • 不能保证原子性

volatile修饰的变量的内存语义:

  • 当写volatile变量时,JMM会把线程对应的本地内存中的共享变量值立即刷新回主内存中。
  • 当读volatile变量时,JMM会把线程对应的本地内存设置为无效,重新回到主内存中读取最新共享变量的值。

volatile写语义是直接刷新主内存中,读的语义是直接从主内存中读取。

volatile内存屏障

内存屏障:一类同步屏障指令,是CPU或编译器在对内存随机访问的操作中的一个同步点,使得在此点之前的所有读写操作都执行后才可以开始执行词典之后的操作,避免代码重排序。

内存屏障其实就是一种JVM指令,Java内存模型的重排规则会要求Java编译器在生成JVM指令时插入特定的内存屏障指令,通过这些内存屏障指令,volatile实现了Java内存模型中的可见性和有序性。

内存屏障保证可见性:

  1. 写屏障:保证在该屏障之前的对共享变量改动的操作,都同步到主内存中。
  2. 读屏障:保证在该屏障之后的,对共享变量的读取操作,从主内存刷新变量值,加载的是主内存中最新数据。
  3. 全能屏障:兼具写屏障和读屏障的功能。

内存屏障保证有序性:

  1. 写屏障会确保指令重排序时,不会将写屏障之前的代码排在写屏障之后。
  2. 读屏障会确保指令重排序时,不会将读屏障之后的代码排在读屏障之前。

volatile保证可见性

volatile关键字在Java中可以用来确保多个线程能够看到共享变量的最新值,从而保证可见性。

  • 当写volatile变量时,JMM会把线程对应的本地内存中的共享变量值立即刷新回主内存中。
  • 当读volatile变量时,JMM会把线程对应的本地内存设置为无效,重新回到主内存中读取最新共享变量的值。

volatile变量读写过程:

read(读取) --> load(加载) --> use(使用) --> assign(赋值) --> store(存储) --> write(写入) --> lock(锁定) --> unlock(解锁)

这里的lock与unlock和前面的学过的JUC锁是不同的概念

volatile变量读写过程.png

volatile不能保证原子性

volatile虽然可以保证多个线程之间对同一个变量的修改可见,但是它并不能保证对该变量的操作是原子的。

volatile不能保证原子性的Demo:

number.num的结果往往不是预期的10000,因为num++操作底层是三步,间隙期间不同步非原子操作。对于volatile变量,JVM只是保证从主内存加载到线程工作内存的值是最新的,也就是数据加载时是最新的,如果第二个线程在第一个线程读取旧值和写回新值期间读取i的旧值,也就造成了线程安全问题。

/**
 * @author 文轩
 * @create 2024-03-16 20:24
 *
 * volatile关键字不保证原子性
 */
public class VolatileNonAtomicityDemo {

    public static void main(String[] args) throws InterruptedException {

        Number number = new Number();

        for (int i = 0; i < 10; i++) {
            new Thread(() -> {
                for (int j = 0; j < 1000; j++) {
                    number.addNumber();
                }
            }).start();
        }

        TimeUnit.SECONDS.sleep(1);
        System.out.println("num = " + number.num);

    }
}

class Number {
    volatile int num = 0;

    public void addNumber() {
        num++;
    }
}

volatile保证有序性

volatile通过内存屏障保证有序性

  • 在每一个volatile写操作前面插入一个StoreStore屏障,保证volatile写之前,其前面的所有普通写操作都已经刷新到主内存中。
  • 在每一个volatile写操作后面插入一个StoreLoad屏障,避免volatile写与后面可能的volatile读写操作重排序。
  • 在每一个volatile读操作后面插入一个LoadLoad屏障,LoadLoad屏障用来禁止处理器volatile读与后面普通读操作重排序。
  • 在每一个volatile读操作后面插入一个LoadStore屏障,LoadStore屏障用来禁止处理器把volatile读与后面的普通写重排序。

volatile使用场景

  1. 变量指存在单一赋值的话,加上voletile后可以保证变量多线程安全。但如果变量存在复合运算赋值则无法保证多线程安全。
  • volatile变量保证可见性,所以如果是单一赋值的话可以保证多线程安全。
  • volatile变量无法保证原子性,所以如果存在符合运算赋值则无法保证多线程安全。

单一赋值:a = 10;

复合运算赋值:a++;

  1. 开销较低的读,写锁策略
  • 当业务读远多于写,结合内部锁和volatile变量来减少同步的开销。
class Counter {
    private volatile int count = 0;

    public synchronized void increment() {
        count++;
    }

    public int get() {
        // 这里无需加锁,volatile可见性可以保证多线程数据安全
        return count;
    }
}
  1. DCL双端锁的发布

通过volatile的禁止指令重排,使得双端锁能够正常实现:

class SafeDoubleCheckSingleton {
    private volatile static SafeDoubleCheckSingleton singleton;

    private SafeDoubleCheckSingleton() {}

    public static SafeDoubleCheckSingleton getInstance() {
        if(singleton == null) {
            synchronized (SafeDoubleCheckSingleton.class) {
                //  再次检查,防止多个线程同时通过第一个if
                if (singleton == null) {
                    // 如果不加volatile,那么创建对象操作可能发生指令重排,导致对象创建失败
                    singleton = new SafeDoubleCheckSingleton();
                }
            }
        }

        return singleton;
    }
}

比较并交换CAS

CAS概述

CAS(Compare And Swap),中文翻译比较并交换,用于保证共享变量的原子性更新,比较操作和更新操作是底层硬件实现原子操作。CAS包含三个操作数(内存位置、预期原值和更新值),修改值时首先比较内存位置的值与预期原值是否相等,如果相等将内存位置的值设置为新值;如果不相等,则不做任何操作。

image.png

不使用锁实现线程安全:

class Count {
    private static AtomicInteger count = new AtomicInteger();

    public void add() {
        count.incrementAndGet();
    }

    public int get() {
        return count.get();
    }
}

CAS底层原理

  1. AtomicInteger原子类底层通过CAS + volatile 实现多线程数据安全
class Count {
    private static AtomicInteger count = new AtomicInteger();

    public void add() {
        count.incrementAndGet();
    }
}
  1. AtomicInteger原子类的incrementAndGet()方法调用Unsafe类的getAndAddInt方法
public final int incrementAndGet() {
    return unsafe.getAndAddInt(this, valueOffset, 1) + 1;
}
  1. Unsafe类的getAndAddInt方法通过自旋比较并交换的方式
  • this.getIntVolatile(var1, var2):获取var1在var2地址值的旧值。
  • this.compareAndSwapInt(var1, var2, var5, var5 + var4):实现比较并交换,如果修改成功则返回true,如果修改失败则返回false。
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;
}
  1. Unsafe类的compareAndSwapInt()方法是一个native方法,通过调用C语言实现比较并交换功能:
  • var1:原子类对象
  • var2:地址值
  • var4:旧值
  • var5:新值
public final native boolean compareAndSwapInt(Object var1, long var2, int var4, int var5);

CAS底层流程

假设线程A和线程B两个线程同时执行getAndAddInt操作(分别泡在不同CPU上):

  1. AtomicInteger里面的value初始值为0,即主内存中AtomicInteger的value为3。根据JMM模型,线程A和线程B各自持有一份值为0的value的副本分别到各自的工作内存。
  2. 线程A通过getIntVolatile(var1, var2)拿到value值为0,这时线程A被挂起。
  3. 线程B也通过getIntVolatile(var1, var2)方法获取到value值为0,此时刚好线程B没有挂起并执行compareAndSwapInt方法比较内存值也为3,成功修改内存值为1,线程B执行成功。
  4. 线程A恢复,执行compareAndSwapInt方法比较,发现0和内存值1不同,说明该值被修改过,那么A线程本次修改失败,所以重新读取再来一遍。
  5. 线程A重新获取value值,因为变量value被volatile修饰,所以B线程对它的修改,线程A能够看到,线程A继续执行compareAndSwapInt进行比较交换,直到成功。

CAS的缺点

  1. 循环比较并交换占用CPU,开销较大

Unsafe类的getAndAddInt()方法,存在do...while循环比较并交换,CPU开销较大。

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;
}
  1. 存在ABA问题

ABA问题:CAS算法是提取内存中某时刻的数据并再当下时刻的值比较并交换,那么在这个时间差可能会导致其他线程将数据改变,如果数据变化从A修改成B,又将B修改成A,则内存值原来是A,修改后也是A,导致该线程认为数据没有发生变化,可以修改。

解决ABA问题:在每次共享数据进行修改时,同时维护一个版本号,只有版本号匹配的情况下才能进行CAS操作。这样即使值由A变为B再变回A,版本号也会发生变化,CAS操作就能正确判断出值已经被修改过了。

JDK提供版本号时间戳原子引用利用版本号解决CAS的ABA问题:

  • public AtomicStampedReference(V initialRef, int initialStamp):初始值和初始版本号。
  • public boolean compareAndSet(V expectedReference, V newReference, int expectedStamp, int newStamp):期望引用和期望版本号都一致才能进行CAS修改数据。
  • public void set(V newReference, int newStamp):设置值和版本号。
  • public V getReference():返回引用的值。
  • public int getStamp():返回当前版本号。

原子操作类Atomic

基本类型原子类

基本类型原子类:

  • AtomicInteger:整型原子类
  • AtomicBoolean:布尔型原子类
  • AtomicLong:长整型原子类

基本类型原子类常用API:

  • public final int get():获取当前的值。
  • public final int getAndSet(int newValue):获取当前的值,并设置新的值。
  • public final int getAndIncrement():获取当前的值,并自增。
  • public final int getAndDecrement():获取当前的值,并自减。
  • public final int getAndAdd(int delta):获取当前的值,并加上预期的值。
  • boolean compareAndSet(int expect, int update):如果输入的数值等于预期值,则以原子方式将该值设置为输入值(update)。
  • public final void lazySet(int newValue):最终设置为newValue,使用 lazySet 设置之后可能导致其他线程在之后的一小段时间内还是可以读到旧的值。

数组类型原子类

数组类型原子类:

  • AtomicIntegerArray:整型数组原子类
  • AtomicLongrArray:长整型数组原子类
  • AtomicReferenceArray:用类型数组原子类

数组类型原子类常用API:

  • public final int get(int i):获取 index=i 位置元素的值。
  • public final int getAndSet(int i, int newValue):返回 index=i 位置的当前的值,并将其设置为新值:newValue。
  • public final int getAndIncrement(int i):获取 index=i 位置元素的值,并让该位置的元素自增。
  • public final int getAndDecrement(int i):获取 index=i 位置元素的值,并让该位置的元素自减。
  • public final int getAndAdd(int i, int delta):获取 index=i 位置元素的值,并加上预期的值。
  • boolean compareAndSet(int i, int expect, int update):如果输入的数值等于预期值,则以原子方式将 index=i 位置的元素值设置为输入值(update)。
  • public final void lazySet(int i, int newValue):最终 将index=i 位置的元素设置为newValue,使用 lazySet 设置之后可能导致其他线程在之后的一小段时间内还是可以读到旧的值。

引用类型原子类

  • AtomicReference:引用类型原子类
  • AtomicStampedReference:原子更新带有版本号的引用类型。该类将整数值与引用关联起来,可用于解决原子的更新数据和数据的版本号,可以解决使用 CAS 进行原子更新时可能出现的 ABA 问题。
  • AtomicMarkableReference:原子更新带有标记的引用类型。该类将 boolean 标记与引用关联起来。

对象属性修改原子类

  • AtomicIntegerFieldUpdater:原子更新对象中int类型字段的值。
  • AtomicLongFieldUpdater:原子更新对象中Long类型字段的值。
  • AtomicReferenceFieldUpdater:原子更新对象中引用类型字段的值。
/**
 * @author 文轩
 * @create 2024-03-20 11:00
 *
 * 对象属性修改原子类
 */
public class AtomicFieldDemo {

    public static void main(String[] args) throws InterruptedException {
        BankAccount bankAccount = new BankAccount();
        CountDownLatch countDownLatch = new CountDownLatch(10);
        for (int i = 0; i < 10; i++) {
            new Thread(() -> {
                for (int j = 0; j < 1000; j++) {
                    bankAccount.transferMoney(bankAccount);
                }
                countDownLatch.countDown();
            }).start();
        }

        countDownLatch.await();
        System.out.println(bankAccount.money);
    }
}

class BankAccount {
    public volatile int money;

    AtomicIntegerFieldUpdater<BankAccount> atomicIntegerFieldUpdater = AtomicIntegerFieldUpdater.newUpdater(BankAccount.class, "money");

    public void transferMoney(BankAccount bankAccount) {
        atomicIntegerFieldUpdater.getAndIncrement(bankAccount);
    }
}

原子操作增强类

原子操作增强类API

原子增强类:

  • DoubleAccumulator:一个或多个变量,它们一起保持运行double使用所提供的功能更新值。
  • DoubleAdder:一个或多个变量一起保持初始为零double总和。
  • LongAccumulator:一个或多个变量,一起保持使用提供的功能更新运行的值long ,提供了自定义的函数操作。
  • LongAdder:一个或多个变量一起维持初始为零long总和(重点),只能用来计算加法,且从0开始计算。

原子增强类API:

  • void add(long x):将当前的value加x。
  • void increment():将当前的value加1。
  • void decrement():将当前的value减1。
  • long sum():返回当前值,注意在没有并发更新value的情况下,sum会返回一个精确值,在存在并发的情况下,sum不保证返回精确值。
  • void reset():将value重置为0,可用于替代重新new一个LongAddr,但此方法只可以在没有并发更新的情况下使用。
  • long sumThenReset():获取当前value,并将value重置为0。
/**
 * @author 文轩
 * @create 2024-03-20 11:33
 *
 * LongAddr原子操作类Demo
 *
 */
public class LongAdderDemo {
    public static void main(String[] args) throws InterruptedException {

        Count count = new Count();
        CountDownLatch countDownLatch = new CountDownLatch(10);

        for (int i = 0; i < 10; i++) {
            new Thread(() -> {
                for (int j = 0; j < 1000; j++) {
                    count.add();
                }
                countDownLatch.countDown();
            }).start();
        }
        countDownLatch.await();
        System.out.println(count.longAdder.sum());
    }
}

class Count {
    public LongAdder longAdder = new LongAdder();

    public void add() {
        longAdder.increment();
    }
}

原子操作实现方法性能对比

原子操作实现方法:

  1. 添加synchronized关键字
  2. 通过AtomicLong原子类
  3. 通过LongAdder原子类
  4. 通过LongAccumutor原子类
/**
 * @author 文轩
 * @create 2024-03-20 11:45
 *
 * 实现原子操作的方法对比
 */
public class AtomicCompareDemo {
    public static void main(String[] args) throws InterruptedException {
        AtomicCompare atomicCompare = new AtomicCompare();
        CountDownLatch countDownLatch = new CountDownLatch(50);

        long start = System.currentTimeMillis();
        for (int i = 0; i < 50; i++) {
            new Thread(() -> {
                try {
                    for (int j = 0; j < 1000000; j++) {
                        atomicCompare.add();
                        // atomicCompare.addAtomicLong();
                        // atomicCompare.addLongAdder();
                        // atomicCompare.addLongAccumulator();
                    }
                } finally {
                    countDownLatch.countDown();
                }
            }).start();
        }
        countDownLatch.await();
        long end = System.currentTimeMillis();

        System.out.println("synchronized关键字耗时:" + (end - start));   // 2247
        // System.out.println("AtomicLong耗时:" + (end - start));       // 670
        // System.out.println("LongAdder耗时:" + (end - start));        // 118
        // System.out.println("LongAccumulator耗时:" + (end - start));  // 136
    }
}

class AtomicCompare {

    // 通过synchronized关键字实现
    public long number = 0;
    public synchronized void add() {
        number++;
    }

    // 通过AtomicLong实现
    public AtomicLong atomicLong = new AtomicLong(0);
    public void addAtomicLong() {
        atomicLong.incrementAndGet();
    }

    // 通过LongAdder实现
    public LongAdder longAdder = new LongAdder();
    public void addLongAdder() {
        longAdder.increment();
    }

    // 通过LongAccumulator实现
    public LongAccumulator longAccumulator = new LongAccumulator((x,y) -> x+y, 0);
    public void  addLongAccumulator() {
        longAccumulator.accumulate(1);
    }
}

原子操作增强类源码分析

LongAdder底层的父类是Striped64,Striped64的父类是Number。

  • LongAdder的基本思想是分散热点,将value值分散到一个Cell数组中,不同线程会命中数组的不同槽中,各个线程只对自己槽中的值进行CAS操作,这样冲突的概率就小很多。当需要获取真正的long值时,只需要将各个槽中的变量值累加返回。
abstract class Striped64 extends Number {
	transient volatile Cell[] cells;
    transient volatile long base;
}
  1. LongAdder类的increment底层是调用add(1L)方法
public void increment() {
    add(1L);
}
  1. add(1L)底层逻辑如下:
    1. 最初无竞争时只更新base属性。
    2. 如果更新base失败后,首次新建一个大小为2的Cell[]数组。
    3. 当多个线程竞争同一个Cell比较激烈时,就会对Cell[]数组进行扩容。
public void add(long x) {
    Cell[] as; long b, v; int m; Cell a;
    if ((as = cells) != null || !casBase(b = base, b + x)) {
        boolean uncontended = true;
        if (as == null || (m = as.length - 1) < 0 ||
            (a = as[getProbe() & m]) == null ||
            !(uncontended = a.cas(v = a.value, v + x)))
            longAccumulate(x, null, uncontended);
    }
}
  1. longAccumulate(x, null, uncontended)的逻辑如下:

    1. 如果Cell[]数组为空,则为Cell[]进行初始化。
    2. 如果有线程在给Cell[]数组初始化,其他线程也进来,则对base进行cas加1操作。
    3. Cell数组不为空且存在多个线程竞争激烈,则为Cell[]数组进行扩容。
  2. sum()方法的原理如下:

sum()会将所有Cell[]数组中的value和base累加作为返回值,sum()执行时,没有限制base和cells的更新,所以sum()不是强一致性,sum()返回的值只是一个大约值,并不是精确值。

public long sum() {
    Cell[] as = cells; Cell a;
    long sum = base;
    if (as != null) {
        for (int i = 0; i < as.length; ++i) {
            if ((a = as[i]) != null)
                sum += a.value;
        }
    }
    return sum;
}

ThreadLocal

ThreadLocal介绍

ThreadLocal类用来提供线程内部的局部变量,这种变量在多线程环境下访问时能保证各个线程的变量相对独立于其他线程内的变量,变量分配在堆内的TLAB中。

ThreadLocal示例通常来说都是private static类型的,属于线程的本地变量,用于关联线程和线程上下文。每个线程都会在ThreadLocal中保存一份该线程独有的数据,所以是线程安全的。

ThreadLocal作用:

  • 线程并发:应用在线程并发场景下
  • 传递数据:通过ThreadLocal实现在同一个线程不同函数或组件中传递公共变量,减少传递复杂度。
  • 线程隔离:每个线程的变量都是独立的,不会互相影响。

ThreadLocal和synchronized的对比:

  • ThreadLocal采用空间换时间的方式,为每个线程提供一份变量,从而实现同时访问互不干扰,多线程中每个线程之间的数据相互隔离。
  • synchronized采用时间换空间的方式,只提供一份变量,让不同的线程排队访问,多个线程之间需要同步访问资源。

ThreadLocal的API

  • T get():返回当前线程的此线程局部变量的副本中的值。
  • void remove():删除此线程局部变量的当前线程的值。
  • void set(T value):将当前线程的此线程局部变量的副本设置为指定的值。
  • protected T initialValue():返回此线程局部变量的当前线程的初始值。ThreadLocal通过匿名内部类重写该方法,来初始化ThreadLocal局部变量的初始值。JDK8之后一般通过withInitial方法来初始化,可以使用Lambda表达式。
  • static ThreadLocal withInitial(Supplier supplier):创建线程局部变量。

ThreadLocal代码使用示例

/**
 * @author 文轩
 * @create 2024-03-21 11:48
 *
 * ThreadLocal的Demo
 */
public class ThreadLocalDemo {
    public static void main(String[] args) {
        House house = new House();
        for (int i = 0; i < 5; i++) {
            new Thread(() -> {
                int num = new Random().nextInt(5) + 1;
                try {
                    for (int j = 0; j < num; j++) {
                        house.saleVolumeByThreadLocal();
                    }
                    System.out.println(Thread.currentThread().getName() + "卖出" + num + "套房子");
                } finally {
                    house.saleVolume.remove();
                }
            }, String.valueOf(i)).start();
        }
    }
}

class House {
    private Integer saleCount = 0;

    public ThreadLocal<Integer> saleVolume = ThreadLocal.withInitial(() -> 0);
    public void saleVolumeByThreadLocal() {
        saleVolume.set(saleVolume.get() + 1);
    }
}

ThreadLocal底层原理

ThreadLocal的底层原理:

  • Thread类维护着一个ThreadLocalMap属性,所以每个Thread对象都有一个ThreadLocalMap。
  • ThreadLocalMap是ThreadLocal类的静态内部类,ThreadLocalMap类维护着一个Entry[]数组。
  • Entry是ThreadLocalMap类的静态内部类,Entry维护着key、value键值对,将ThreadLocal对象作为key值。
  • ThreadLocal类提供get()、set()方法:
    • get():通过当前线程的Thread对象来获取ThreadLocalMap对象,再通过ThreadLocal对象作为key获取对应Entry对象的value属性值。
    • set():通过当前线程的Thread对象来获取ThreadLocalMap对象,再将ThreadLocal对象作为key,值Value设置到对应的Entry对象的属性中。

ThreadLocal底层原理.png

ThreadLocal的内存泄漏问题

内存泄漏:指程序中动态分配的堆内存由于某种原因未释放或无法释放,造成系统内存的浪费,导致程序运行速度减慢甚至系统崩溃等严重后果。

JVM的强软弱虚引用

  • 强引用:对于强引用的对象,就算出现OOM也不会对该对象进行回收,只要强引用存在,那么该对象就不会被回收。
  • 软引用:对于软引用的对象,当系统内存足够时,软引用对象不会被回收;当系统内存不够时,软引用就会被回收。
  • 弱引用:对于弱引用,只要JVM垃圾回收机制一运行,不管JVM内存是否足够,弱引用对象就都会被回收。
  • 虚引用:虚引用必须和引用队列联合使用,同时虚引用是无法获取对象的,只会返回null,虚引用仅仅是提供了一种确保对象被finalize后,做某些事情的通知机制。

ThreadLocal底层的Entry使用的是弱引用

static class Entry extends WeakReference<ThreadLocal<?>> {
    /** The value associated with this ThreadLocal. */
    Object value;

    Entry(ThreadLocal<?> k, Object v) {
        super(k);
        value = v;
    }
}

为什么ThreadLocal底层的Entry的key是弱引用?

  • 如果key是强引用,使用完ThreadLocal,虽然ThreadLocal Ref引用被回收,但是ThreadLocalMap的Entry强引用了ThreadLocal,造成ThreadLocal对象无法被回收,会导致内存泄漏。

JUC-ThreadLocal内存泄漏强引用.png

  • 如果key是弱引用,使用完ThreadLocal,ThreadLocal Ref被回收,ThreadLocalMap只持有ThreadLocal的弱引用,所以ThreadLocal对象也会被回收,此时Entry中的key能够成功回收。

JUC-ThreadLocal内存泄漏弱引用.png

ThreadLocal底层Entry的value内存泄漏问题

  • Entry的弱引用能够保证key指向的ThreadLocal对象能够成功被回收,但是value指向的对象需要ThreadLocalMap调用get()、set()时发现key为null时才会回收整个entry和value对象,所以弱引用无法保证value对象不会导致内存泄漏,value对象手动通过ThreadLocal的remove()来删除。

ThreadLocal的最佳实践

ThreaaLocal的最佳实践:

  1. ThreadLocal一定要初始化,避免空指针异常,通过ThreadLocal的静态方法withInitial进行初始化。
  2. ThreadLocal对象建议修饰为static,因为ThreadLocal对象可以所有线程共享同一个ThreadLocal对象,可以避免ThreadLocal对象被重复创建,避免内存浪费。
  3. ThreadLocal对象使用完之后一定要记得调用remove方法,避免value对象没有被回收,导致内存泄漏问题。

抽象队列同步器AQS

AQS介绍

AQS:Java中用于构建同步器的抽象基类,在Java并发编程中,AQS提供支持基于FIFO等待队列的同步器实现,如ReentrantLock、Semaphore、CountDownLatch等。

AQS通过状态属性(status)来表示资源的状态:

  • 独占模式:只有一个线程能够访问资源,如ReentrantLock。
  • 共享模式:允许多个线程访问资源,如Semaphore、ReentrantReadWriteLock。

AQS核心思想:

  • 如果被请求的共享资源空闲,则将当前请求资源的线程设置为有效的工作线程,并将共享资源设置为锁定状态。
  • 如果被请求的共享资源被占用,AQS用队列实现线程阻塞等待以及被唤醒时锁分配的机制,将暂时获取不到锁的线程加入到队列中。

CLH是基于单向链表的高性能、公平的自旋锁,AQS将每条请求共享资源的线程封装成一个CLH锁队列的一个结点(Node)来实现锁的分配。

JUC-AQS原理图.png

AQS的内部结构

public abstract class AbstractQueuedSynchronizer {
    // 双向队列的头节点
    private transient volatile Node head;
    // 双向队列的尾结点
    private transient volatile Node tail;
    // 表示AQS的同步状态
    private volatile int state;
    
    // 双向队列的节点类
    static final class Node { 
        // 共享模式等待锁的线程
        static final Node SHARED = new Node();
        // 独占模式等待锁的线程
        static final Node EXCLUSIVE = null;
        // 当前节点在队列中的状态
        volatile int waitStatus;
        // 前驱指针
        volatile Node prev;
        // 后继指针
        volatile Node next;
        // 表示处于该节点的线程
        volatile Thread thread;
        // 指向下一个处于CONDITION状态的节点
        Node nextWaiter;
        // 表示线程获取锁的请求已经取消了
        static final int CANCELLED =  1;
        // 表示线程已经准备好了,就等资源释放
        static final int SIGNAL    = -1;
        // 表示节点在等待队列中,节点线程等待唤醒
        static final int CONDITION = -2;
        // 当前线程处在SHARED情况下,该字段才会使用
        static final int PROPAGATE = -3;
    }
}

AQS设计原理

AQS获取锁

while(state 状态不允许获取) {
    if(队列中还没有此线程) {
        入队并阻塞 park
    }
}
当前线程出队

AQS释放锁

if(state 状态允许了) {
    恢复阻塞的线程(s) unpark
}

AQS的state字段:独占模式0表示未加锁状态,大于0表示已经加锁状态。共享模式时表示剩余许可数。

AQS的Node节点的waitState字段:表示Node节点的状态,一个Node节点即表示一个线程:

  • Node.CANCELLED = 1:表示超时或中断,不会再改变状态。
  • Node.SIGNAL = -1:表示此节点后面的节点被阻塞。
  • Node.CONDIION = -2:表示此节点在阻塞队列中。
  • Node.PROPAGATE = -3:表示将releaseShared传播到其他节点。

AQS阻塞和恢复:

  • 通过LocalSupport的park和unpark来实现线程的暂停和恢复。
  • park线程可以通过interrupt打断。

AQS的阻塞队列设计:使用FIFO先入先出队列,同步队列是双向链表。

AQS-底层阻塞队列.png

AQS底层原理

很多同步类都是基于AQS实现的,比如ReentrantLock、Semaphore、CountDownLatch等。

  1. ReentrantLock有一个抽象静态内部类Sync,Sync继承AbstractQueuedSysnchronizer类:
  • NonfairSync(非公平锁)继承Sync
  • FairSync(公平锁)继承Sync
public class ReentrantLock implements Lock {
    abstract static class Sync extends AbstractQueuedSynchronizer {
    }
    
    static final class NonfairSync extends Sync {
    }
    
    static final class FairSync extends Sync {
    }
}
  1. ReentrantLock的非公平锁的lock方法
  • 如果是第一个线程抢占,则可以抢占成功,同时设置锁状态(state)为1。
  • 如果是非第一个线程抢占,说明存在其他线程在之前已经将锁抢占,则将线程添加到阻塞队列中,并阻塞等待。
static final class NonfairSync extends Sync {
    final void lock() {
        if (compareAndSetState(0, 1))
            setExclusiveOwnerThread(Thread.currentThread());
        else
            acquire(1);
    }
}
  1. 线程抢占锁失败,执行AbstractQueuedSysnechronizer的acquire方法:
  • tryAcquire:再次尝试获取线程锁
  • addWaiter:将当前线程添加到阻塞队列中
  • acquireQueued:修改线程的状态,同时调用LockSupport.park将线程阻塞,等待其他线程将其唤醒。
public abstract class AbstractQueuedSynchronizer {
    public final void acquire(int arg) {
        if (!tryAcquire(arg) &&
            acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
            selfInterrupt();
    }
}
  1. 解锁调用unlock方法
public class ReentrantLock implements Lock {
    public void unlock() {
        sync.release(1);
    }
}
  1. 调用AbstractQueuedSysnchronizer的release方法,尝试释放锁。
public abstract class AbstractQueuedSynchronizer
    public final boolean release(int arg) {
        if (tryRelease(arg)) {
            Node h = head;
            if (h != null && h.waitStatus != 0)
                unparkSuccessor(h);
            return true;
        }
        return false;
    }
}

JUC提供的同步器

CountDownLatch

CountDownLatch是java并法包中的一个工具类,可以用于控制一个或多个线程等待其他线程完成某些操作。

CountDownLatch的底层是基于内部的计数器,在创建CountDownLatch对象时,需要指定一个初始的计数值,当调用CountDownLatch的countDown方法时,计数值会减1,当计数值减至0时,所有等待线程都会被唤醒。

CountDownLatch的使用:

  1. 通过构造器创建CountDownLatch,同时初始化唤醒CountDownLatch需要down多少步。
  2. CountDownLatch的await方法会让当前线程进入阻塞状态,当CountDownLatch的计数器减至0才会被自动唤醒。
  3. CountDownLatch的countDown方法不会阻塞线程,可以让计数值减1。
/**
 * 验证六个同学离开教室后,班长才能锁门
 * @author 文轩
 * @create 2024-02-03 8:05
 */
public class CountDownLatchDemo {
    public static void main(String[] args) throws InterruptedException {

        CountDownLatch countDownLatch = new CountDownLatch(6);

        for (int i = 1; i <= 6; i++) {
            new Thread(() -> {
                System.out.println(Thread.currentThread().getName() + "同学离开教室");
                countDownLatch.countDown();
            }, String.valueOf(i)).start();
        }

        countDownLatch.await();
        System.out.println("班长最后离开教室");
    }
}

CyclicBarrier

CyclicBarrier是Java并发包中的一个同步工具类,可以使一组线程在达到某个共同点前互相等待,然后一起执行后续操作。

CyclicBarrier的底层原理是基于内部的计数器和锁机制。在创建CyclicBarrier对象时,需要指定一个计数值和一个等待的动作。当线程调用CyclicBarrier的await方法时,会等待其他线程到达屏障点,当所有线程都到达屏障点时,会执行指定的等待动作,之后所有线程一起继续执行后续操作。

CyclicBarrier的使用

  1. CyclicBarrier的构造器的两个参数:
    • parties:代表多少个线程到达屏障开始触发线程任务
    • barrierAction:线程任务
  2. await方法,线程调用await方法通知CyclicBarrier本线程已经到达屏障
/**
 * @author 文轩
 * @create 2024-02-03 10:49
 */
public class CyclicBarrierDemo {
    public static void main(String[] args) {
        CyclicBarrier cyclicBarrier = new CyclicBarrier(7, () -> {
            System.out.println("完成所有任务,执行barrierAction");
        });

        for (int i = 0; i < 7; i++) {
            new Thread(() -> {
                System.out.println(Thread.currentThread().getName() + " 正在执行");
                try {
                    cyclicBarrier.await();
                } catch (Exception e) {
                    throw new RuntimeException(e);
                }
            }, String.valueOf(i)).start();
        }
    }
}

Semaphore

Semaphore是Java并发包中的一个同步工具类,用于控制同时访问某个资源线程数量。

Semaphore的原理是基于内部的计数器和条件队列。在创建Semaphore对象时,需要指定一个许可数,线程在访问受Semaphore保护的资源前,需要先通过调用Semaphore的acquire方法获取许可,如果许可数量不足,则线程会被阻塞。当线程使用完资源后,需要通过调用Semaphore的release方法释放许可,以便其他等待的线程可以获取许可并访问资源。

Semaphore的使用:

  1. Semaphore构造器,可以指定线程许可的数量,第二个参数指定获取许可是否公平。
  2. acquire方法:表示获取许可,Semaphore许可数量减1。
  3. release方法:表示释放许可,Semaphore许可数量增1。
/**
 * @author 文轩
 * @create 2024-02-03 10:57
 */
public class SemaphoreDemo {
    public static void main(String[] args) {
        Semaphore semaphore = new Semaphore(3);

        for (int i = 0; i < 6; i++) {
            new Thread(() -> {
                try {
                    semaphore.acquire();
                    System.out.println(Thread.currentThread().getName() + "获取到位置");
                    TimeUnit.SECONDS.sleep(3);
                    semaphore.release();
                    System.out.println(Thread.currentThread().getName() + "释放了位置");
                } catch (Exception e) {
                    throw new RuntimeException(e);
                }
            }, String.valueOf(i + 1)).start();
        }
    }
}

集合安全问题

CopyOnWriteArrayList

CopyOnWriteArrayList采用写时复制计数,增删改操作会将底层数组拷贝一份,在新数组上执行操作,不影响其他线程的并发读操作,最后副本将原来的数据进行覆盖。

CopyOnWriteArrayList的使用

public class CopyOnWriteArrayListDemo {

    public static void main(String[] args) {
        List<String> list = new CopyOnWriteArrayList<>();

        for (int i = 0; i < 30; i++) {
            new Thread(()  -> {
                list.add(UUID.randomUUID().toString());
                System.out.println(list);
            }).start();
        }
    }
}

ConcurrentHashMap

ConcurrentHashMap通过分段锁解决并发读写问题,将整个数据分成多个段,每个段内部都有一个独立的锁,不同段的数据可以并发读写。

public class ConcurrentHashMapDemo {

    public static void main(String[] args) {
        Map<String, String> map = new ConcurrentHashMap<>();

        for (int i = 0; i < 30; i++) {
            new Thread(() -> {
                String value = UUID.randomUUID().toString();

                map.put(value, value);
                System.out.println(map);
            }).start();
        }
    }
}

文章来源出处

文章来源出处

  1. 尚硅谷周阳老师JUC:www.bilibili.com/video/BV1ar…
  2. 尚硅谷JUC并发编程:www.bilibili.com/video/BV1Kw…
  3. 网络图片,如有侵权可直接联系删除。