3_并发篇

并发相关基本概念

进程和线程

  • 进程:进程是资源分配的最小单位,每个进程都有自己的独立空间,包括代码、数据、堆栈等,不同进程之间相互独立,彼此隔离,每个进程拥有自己的地址空间和系统资源。
  • 线程:线程是CPU调度的最小单位,一个进程可以拥有多个线程,线程共享进程的资源,包括内存、文件句柄等,多个线程可以同时执行不同的任务,实现并发处理。

串行、并行与并发

  • 串行(Serializable):所有任务依次执行。例如有一台咖啡机,这条咖啡机排了一条队,大家都按照排队的顺序去接咖啡。

  • 并行(Parallel):多个任务同时运行。例如同时有两台咖啡机,每台咖啡机都可以有人排队接咖啡,两台咖啡机是同时运行的。

  • 并发(Concurrent):多个任务在一个时间段内交替运行。从宏观的角度看这一段时间虽然所有任务是同时进行的,但是实际上是多个任务在极短的时间内交替运行。例如有一台咖啡机,但是这台咖啡机前排了两条队,两条队的人交替接咖啡,整体看起来两条队都在接咖啡,但是实际上同一时刻只有一条队的人能接咖啡。

什么是线程安全

线程安全是指在多线程环境下,所有线程都能够正确的处理线程之间的共享资源,能够按照预定流程正确的执行并且给出正确的结果。

线程上下文切换

当前任务在执行完 CPU 时间片切换到另一个任务之前会先保存自己的状态,以便下次再切换回这个任务时,可以再加载这个任务的状态。任务从保存到再加载的过程就是一次上下文切换

线程调度器(Thread Scheduler)和时间分片(Time Slicing)

  • 线程调度器:线程调度器是操作系统中的一部分,用于管理和控制线程的执行顺序。它负责将可运行状态的线程分配到处理器上,并根据一定算法确定哪个线程应该优先执行。线程调度器通常会采用抢占式调度算法,即在CPU空闲时,立即选择最高优先级的线程来执行。
  • 时间分片:时间分片是一种调度算法,指将处理器的时间片分配给多个线程,使得每个线程都可以在一段时间内获得CPU的使用权。当一个线程的时间片用完后,线程调度器会中断该线程的执行,并将处理器分配给下一个线程,这样就能够实现多个线程之间的并发执行。

竞态条件

概述:当两个线程竞争同一资源时,如果对资源的访问顺序敏感,就称存在竞态条件。导致竞态条件发生的代码区称作临界区。

解决:在临界区中使用适当的同步就可以避免竞态条件。

Java内存模型(Java Memory Model)

什么是Java内存模型

Java内存模型(简称JMM),本身是一种抽象的概念,并不真实存在,它描述的是一组规则或规范,通过这组规范定义了程序中各个变量(包括实例字段,静态字段和构成数组对象的元素)的访问方式。

由于JVM运行程序的实体是线程,每个线程创建时,JVM都会为其分配工作内存,用于存储线程私有的数据。而Java内存模型中,规定所有变量都存储在主内存,主内存是共享内存区域,所有线程都可以访问。当线程想对一个变量进行赋值/运算等操作时,必须在工作内存中进行。

为此,当线程想操作变量时,首先要将变量从主内存拷贝的自己的工作内存,然后对变量进行操作,操作完成后,再将变更后的值刷写回主内存。也就是说:线程不能直接操作主内存中的变量,为了避免造成数据污染问题,必须将主内存中的变量,拷贝到工作内存中。

注意:Java内存模型和Java内存区域是两个完全不同层次的概念,在理解JMM时,不要带着JVM内存模型的思维去理解。更恰当的说:JMM描述的是一组规则,通过这组规则控制Java程序中,各个变量在共享数据区域和私有数据区域的访问方式,JMM是围绕原子性有序性可见性拓展延伸的。

CPU缓存架构

在进一步了解JMM之前,我们先来看一下CPU的缓存架构是怎样的,如下图所示:

在这里插入图片描述

现代计算机的CPU一般都是多核心CPU,多核是指在一枚处理器中,集成两个或多个完整的计算引擎(核心),这样就可以支持多任务并行执行。从多线程的调度来说,每个线程都会映射到各个CPU核心上并行执行。

在每个CPU核心内部都有一个寄存器,寄存器存储CPU直接访问的数据和指令,一般CPU会从主内存读取数据到寄存器然后进行处理,但是内存的处理速度远远低于CPU,导致CPU在处理指令时大部分时间都在等待内存准备数据上。

为了解决这个问题,于是诞生了三级缓存架构。CPU每次读取数据的顺序就变成了从直接从一级缓存中读取数据,如果一级缓存中不存在,那么会继续依次向下级缓存中找,如果所有的缓存中都不存在需要的数据,那么再去主内存读取,从主内存读取之后会从拷贝到第三级缓存中,然后再拷贝到第二级缓存,再拷贝到一级缓存中。如果修改了变量那么也要从一级缓存依次向下同步,最后将更新值写入到主内存中。缓存的速度从一级到三级越来越慢,缓存大小也越来越大。

由上图可以看到每个CPU核心都有一级缓存(I-Cache表示指令缓存,D-Cache表示数据缓存)和二级缓存,也就是说一级缓存和二级缓存是CPU核心私有的,而三级缓存是所有核心共享的。我们刚才也说到CPU读取数据是从主内存逐步拷贝到一级缓存,而修改数据是从一级缓存足逐步同步到主内存中,那么就会出现缓存一致性问题。例如核心1和核心2同时读取了一个主内存中的变量x = 0到各自的缓存中,然后又同时执行x = x + 1,然后将结果写入到主内存中,但是由于两个CPU核心读取到的初始值都是x = 0,所以在执行了x = x + 1之后得到的结果都是x = 1,那么最后两个CPU核心都会将x = 1的结果写入到主内存中,然而理想的结果应该是x = 2,但是现在却是x = 1。现在这个现象,就可以被称为“线程安全问题、数据污染问题、数据不一致问题……”。而CPU从硬件层面使用总线加LOCK锁或者缓存一致性协议(如MESI)解决缓存一致性问题,关于总线加LOCK锁和缓存一致性协议具体的可以自行了解,我这里就不再赘述了。

CPU核心、OS线程、Java线程之间的关系

在Java中,线程是基于一对一模型实现的,所谓的一对一模型,就是在语言级别的层面,去间接调用系统内核的线程模型。当我们在Java中创建并启动一个线程时,JVM会将这个线程会映射到操作系统的一个内核线程上,然后由操作系统的线程调度器分发到某一个CPU核心进行执行。

JMM与硬件内存架构的关系

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

通过前面是分析后,我们可以发现,Java线程的执行,最终都会映射到硬件处理器上执行,但Java内存模型和硬件内存架构并不完全一致。

对于硬件内存来说只有寄存器、高速缓存、主内存的概念,并没有工作内存(线程私有数据区域)和主内存(线程共享数据区域)之分。也就是说JMM内存划分,无法对硬件的内存产生任何影响,因为JMM只是一种抽象的概念,是一组规则,并不实际存在,不管是工作内存的数据还是主内存的数据,对于计算机硬件来说,都有可能存储在计算机主内存、CPU缓存或者寄存器中。

JMM围绕的并发三特性

原子性

原子性指的是一个操作是不可中断的,即使是在多线程环境下,一个操作一旦开始就不会被其他线程影响。

非原子性操作反之,如果一组操作可以被打断,这说明就是一组非原子性操作,就比如说i++就不是一个原子性操作,因为i++操作可以被拆分为以下三步:1、读取变量i的值,2、对i的值进行+1操作,3、将结果写回到变量i中。但是在多线程环境下,可能会出现这种情况:1、线程A读取了i的值,然后被操作系统切换到了线程B,2、线程B也读取了i的值,并对其进行了加1操作,3、线程A再次被切换回来,执行加1操作,结果覆盖了线程B修改后的值。

原子性问题可以使用synchronized关键字或者Lock锁来解决。

可见性

可见性指的是当一个线程修改了某个共享变量的值,其他线程是否能够马上得知这个修改的值

上述已经说过,JMM模型抽象出了线程的工作内存和主内存,与CPU的缓存架构类似,所以也会有当一个线程A修改了共享变量i的值,还未写回主内存时,另外一个线程B又对主内存中的i进行操作,此时线程A工作内存中的i对线程B不可见,这种工作内存与主内存之间同步延迟的现象,就造成了可见性问题。另外指令重排以及编译器优化也可能导致可见性问题,关于指令重排序后续会讲解。

可见性问题可以通过volatile关键字解决。而关于volatile会后续单独讲解。

有序性

在了解有序性之前,我们先说明什么是指令重排

指令重排是指在计算机程序执行过程中,为了提高性能和效率,处理器可能会对代码中的指令顺序进行优化,使得程序的执行速度更快。

代码在执行过程中一般会经历以下三种重排:

  • 编译器优化的重排:编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。
  • 指令并行的重排:现代处理器采用了指令级并行技术来将多条指令重叠执行。如果不存在数据依赖性(即后一个执行的语句,无需依赖前面语句的执行结果),处理器可以改变语句对应机器指令的执行顺序。
  • 内存系统的重排:由于处理器使用缓存和读写缓冲区,这使得加载(load)和存储(store)操作看上去可能是在乱序执行,因为三级缓存的存在,导致内存与缓存的数据同步存在时间差。

其中编译器优化的重排序,属于编译期重排;指令并行的重排、内存系统的重排属于处理器重排。在多线程环境中,这些重排优化可能会导致程序出现内存可见性和有序性问题。

关于编译器优化的重排:

// 主存中的变量
int a = 0;
int b = 0;

// 线程A执行代码:
int x = a;
b = 1;             

// 线程B执行代码:
int y = b;
a = 2;

在上面的代码案例中,当线程A和线程B同时执行,由于并行执行的原因,按理应该是x=0、y=0;这个结果,理论上不会出现x=2、y=1;这种结果,但是实际上这种情况是有概率出现的,因为编译器会对一些前后不依赖/影响、耦合度为0的代码,进行指令重排优化,假设此时编译器对这段代码进行指令重排优化后,出现的情况如下:

// 线程A执行代码:
b = 1;        
int x = a; 

// 线程B执行代码:
a = 2;
int y = b; 

这种情况下再结合之前的线程安全问题一起理解,那就有可能出现x=2、y=1;这种结果,这也就说明在多线程环境下,由于编译器会对代码做指令重排的优化工作(因为一般代码都是由上往下执行,指令重排是OS对单线程代码的优化),最终导致在多线程环境下,多个线程使用变量时无法保证一致性。

这个时候我们再来看有序性,有序性其实就是指程序执行的顺序按照代码的先后顺序执行。但是由于指令重排序问题,代码的执行顺序未必就是编写代码时候的顺序。那么我们应该如何避免指令重排序呢。

在单线程环境下,其实不会存在指令重排序导致线程安全问题,因为就算发生指令重排序,由于所有硬件优化的前提都是必须遵守as-if-serial语义,所以不管怎么排序,都不会、不能影响单线程程序的执行结果,我们将这称之为有序执行。

在多线程环境下我们可以通过volatile关键字禁止指令重排序来保证有序性,另外JMM层面还通过happens-before规则来保证多线程环境下两个操作间的原子性、可见性以及有序性。而关于volatile会后续单独讲解。

as-if-serial

在上面将有序性的时候我们提到了在单线程环境下其实不存在指令重排序的问题,因为编译器和处理器都必须遵守as-if-serial原则,那么什么是as-if-serial原则呢?

as-if-serial 语义的意思指:不管怎么重排序(编译器和处理器为了提高并行度),单线程程序的执行结果不能被改变。为了遵守 as-if-serial 语义,编译器和处理器不会对存在数据依赖关系的操作做重排序,因为这种重排序会改变执行结果。但是,如果操作之间不存在数据依赖关系,这些操作可能被编译器和处理器重排序。

happens-before

在了解JMMhappens-before原则之前,必须先对线程执行过程中,与内存的交互操作要有个简单的认知。Java程序在执行的过程中,实际就是OS在调度JVM的线程执行,执行的过程就是与内存的交互操作,而内存交互操作有8种:

  • lock(锁定):作用于主内存的变量,把一个变量标识为线程独占状态;

  • unlock(解锁):作用于主内存的变量,它把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定;

  • read(读取):作用于主内存变量,它把一个变量的值从主内存传输到线程的工作内存中,以便随后的load动作使用;

  • load(载入):作用于工作内存的变量,它把read操作从主存中变量放入工作内存中;

  • use(使用):作用于工作内存中的变量,它把工作内存中的变量传输给执行引擎,每当虚拟机遇到一个需要使用到变量的值,就会使用到这个指令;

  • assign(赋值):作用于工作内存中的变量,它把一个从执行引擎中接受到的值放入工作内存的变量副本中;

  • store(存储):作用于主内存中的变量,它把一个从工作内存中一个变量的值传送到主内存中,以便后续的write使用;

  • write(写入):作用于主内存中的变量,它把store操作从工作内存中得到的变量的值放入主内存的变量中。

JMM对这八种指令的使用,制定了如下规则:

  • 不允许readloadstorewrite操作之一单独出现。即:使用了read必须load,使用了store必须write
  • 不允许线程丢弃他最近的assign操作,即工作变量的数据改变了之后,必须告知主存;
  • 不允许一个线程将没有assign的数据从工作内存同步回主内存;
  • 一个新的变量必须在主内存中诞生,不允许工作内存直接使用一个未被初始化的变量。就是对变量实施use、store操作之前,必须经过assignload操作;
  • 一个变量同一时间只有一个线程能对其进行lock。多次lock后,必须执行相同次数的unlock才能解锁;
  • 如果对一个变量进行lock操作,会清空所有工作内存中此变量的值,在执行引擎使用这个变量前,必须重新loadassign操作初始化变量的值;
  • 如果一个变量没有被lock,就不能对其进行unlock操作。也不能unlock一个被其他线程锁住的变量;
  • 对一个变量进行unlock操作之前,必须把此变量同步回主内存;

从 JDK5 开始,java 使用新的 JSR -133 内存模型。其中提出了 happens-before 的概念,通过这个概念来阐述操作之间的内存可见性。如果一个操作执行的结果需要对另一个操作可见,那么这两个操作之间必须存在 happens-before 关系。这里提到的两个操作既可以是在一个线程之内,也可以是在不同线程之间。 与程序员密切相关的 happens-before 规则如下:

  • 程序顺序原则:即在一个线程内必须保证语义串行性,也就是说按照代码顺序执行;

  • 锁规则:解锁(unlock)操作必然发生在后续的同一个锁的加锁(lock)之前,也就是说,如果对于一个锁解锁后,再加锁,那么加锁的动作必须在解锁动作之后(同一个锁);

  • volatile规则volatile变量的写,先发生于读,这保证了volatile变量的可见性。简单的理解就是:volatile变量在每次被线程访问时,都强迫从主内存中读该变量的值,而当该变量发生变化时,又会强迫将最新的值刷新到主内存,任何时刻,不同的线程总是能够看到该变量的最新值;

  • 线程启动规则:线程的start()方法先于它的每一个动作,即如果线程A,在执行线程Bstart方法前修改了共享变量的值,那么当线程B执行start方法时,线程A变更过的共享变量,对线程B可见;

  • 传递性优先级规则A先于BB先于C,那么A必然先于C

  • 线程终止规则:线程的所有操作先于线程的终结,Thread.join()方法的作用是等待当前执行的线程终止。假设在线程B终止之前,修改了共享变量,线程A从线程Bjoin方法成功返回后,线程B对共享变量的修改将对线程A可见;

  • 线程中断规则:对线程interrupt()方法的调用,先发生于被中断线程的代码检测到中断事件的发生,可以通过Thread.interrupted()方法检测线程是否中断;

  • 对象终结规则:对象的构造函数执行,结束先于finalize()方法。

happens-before原则无需添加任何手段来保证,这是由JMM规定的,Java程序默认遵守如上八条原则。

关于详细的更加详细的JMM说明可以移步:https://juejin.cn/post/6977323236186914852

线程

线程的创建方式

在Java中原始的创建线程的方式:

  • 继承Thread类,覆写run方法
  • 实现Runnable接口,覆写run方法
  • 实现Callable接口,覆写call方法
  • 使用Timer创建定时器线程

话不多说,代码实现:

/**
 * 继承Thread类,覆写run方法
 */
public class MyThread extends Thread {
    @Override
    public void run() {
        System.out.println("我是继承Thread!");
    }
}


/**
 * 实现Runnable接口,覆写run方法
 */
public class MyRunnable implements Runnable {
    @Override
    public void run() {
        System.out.println("我是实现Runnable!");
    }
}


/**
 *  实现Callable接口,覆写call方法
 */
public class MyCallable implements Callable<String> {
    @Override
    public String call() throws Exception {
        return "我是实现Callable!";
    }
}


/**
 * 线程测试类
 */
public class ThreadTest {

    public static void main(String[] args) throws ExecutionException, InterruptedException {
        // 1、继承Thread类
        MyThread myThread = new MyThread();
        myThread.start();

        // 2、实现Runnable接口
        Thread myRunnable = new Thread(new MyRunnable());
        myRunnable.start();

        // 3、实现Callable接口,搭配FutureTask获取线程返回结果
        FutureTask<String> futureTask = new FutureTask<>(new MyCallable());
        Thread myCallable = new Thread(futureTask);
        myCallable.start();
        System.out.println(futureTask.get());

        // 4、使用Timer创建定时器线程
        Timer timer = new Timer();
        timer.schedule(new TimerTask() {
            @Override
            public void run() {
                System.out.println("我是定时器线程!");
            }
        }, 1000, 2000); // 延迟一秒后执行,后续每隔两秒执行一次
    }

}

注意:

1、通过继承Thread类和实现Runnable接口的方式来创建线程,覆写的run方法是没有返回值的,而通过实现Callable接口覆写的call方法是有返回值的,返回类型就是指定的泛型类型,可以通过搭配Future、FutureTask获取线程异步执行的返回结果。

2、run方法或者call方法只是线程执行的一个普通方法,真正开启一个新的线程来实现多线程的是start方法,在调用了start方法之后线程才拥有了获取cpu时间片的权力,在线程获取到cpu的时间片之后执行的方法就是覆写的run方法或者call方法。

线程的生命周期

在这里插入图片描述

在Java1.5之后,Thread类中就以一个内部枚举的形式定义了线程的生命周期,其分别是:

  • NEW:尚未启动的线程状态。通过new关键字创建了线程对象,但是还未调用start方法。
  • RUNNABLE:可运行线程的线程状态。调用了start方法后的线程状态,可能是在等待获取cpu的时间片,也可能是已经获取到cpu时间片正在执行中。
  • BLOCK:阻塞状态。处于阻塞状态的线程正在等待监视器锁进入同步的块/方法,或者在调用Object.wait后重新进入同步块/方法。
  • WAITING:等待状态。调用以下方法之一线程会处于等待状态:Object.wait(),Thread.join(),Support.park()。 处于等待状态的线程正在等待另一个线程执行特定操作。例如,对某个对象调用了Object.wait()的线程正在等待另一个线程对该对象调用Object.notify()或Object.notifyAll()。
  • TIMED_WAITING:超时等待状态。调用以下方法之一线程会处于超时等待状态:Thread.sleep(long),Object.wait(long),Thread.join(long)等。
  • TERMINATED:线程终止状态。线程已完成执行或者异常退出。

线程管理与通信

线程同步相关方法
  • wait():使一个线程处于等待状态,并且释放所持有的对象的锁。只能在同步代码块中使用。
  • sleep():使一个正在运行的线程处于睡眠状态,是一个静态方法,sleep() 不释放锁,调用此方法要处理 InterruptedException 异常。
  • yield():使当前线程从运行状态变为就绪状态。
  • notify():唤醒一个处于等待状态的线程,当然在调用此方法的时候,并不能确切的唤醒某一个等待状态的线程,而是由 JVM 确定唤醒哪个线程,而且与优先级无关。只能在同步代码块中使用。
  • notifyAll():唤醒所有处于等待状态的线程,该方法并不是将对象的锁给所有线程,而是让它们竞争,只有获得锁的线程才能进入就绪状态。只能在同步代码块中使用。
  • interrupt():中断线程,中断并不是真正的结束线程,而只设置标志位(中断位)来通知用户。
  • interrupted():测试当前线程是否已中断,并且清除当前中断状态。
  • isInterrupted():测试此线程是否已中断,不清除中断状态。
线程通信方式
  • 内存共享:多个线程共享同一块内存区域,通过读写该内存区域来实现数据传输。这种方式简单高效,但需要考虑线程安全的问题。
  • 互斥锁:使用互斥锁(Mutex)来保护共享资源的访问,只有持有锁的线程可以访问共享资源,其他线程需要等待锁被释放。互斥锁可以防止多个线程同时访问共享资源,从而避免竞态条件。
  • 条件变量:条件变量(Condition Variable)通过等待和通知机制实现线程之间的同步。一个线程可以通过条件变量等待某个条件满足,而另一个线程可以通过条件变量通知等待的线程条件已经满足,从而实现线程之间的协调与同步。
  • 信号量:信号量(Semaphore)用于控制对共享资源的访问数量。线程可以通过信号量来申请和释放资源,如果资源已经被占用,则等待其他线程释放资源。
  • 管道和队列:管道(Pipe)和队列(Queue)是一种在生产者和消费者之间传递数据的方式。生产者将数据写入管道或队列,消费者从管道或队列中读取数据,实现线程之间的数据传输。
正确结束线程方案
  • 线程的run方法执行完毕,这才是安全有效的的结束一个线程的方法。
  • 使用interrupt方法中断线程,通过interrupted或者isInterrupted方法主动结束。
  • 设置标志位,模仿interrupt方法。
  • Thread.stop,不推荐,因为是不安全的。

线程调度算法

线程调度是指按照特定机制为多个线程分配 CPU 的使用权。

一般有两种调度模型:

  • 分时调度模型:让所有的线程轮流获得 CPU 的使用权,平均分配每个线程占用的 CPU 的时间片。
  • 抢占式调度模型:根据线程优先级、线程饥饿情况等数据算出一个总的优先级,优先让可运行池中优先级高的线程占用CPU,如果可运行池中的线程优先级相同,那么就随机选择一个线程,使其占用CPU。

现在的操作系统一般都采用抢占式调度模型。

线程的优先级

优先级高的线程可以获得较多的执行机会,而优先级低的线程则获得较少的执行机会。

每个线程默认的优先级都与创建它的父线程具有相同的优先级,在默认情况下,main线程具有普通优先级(数值=5)。

可以通过setPriority()方法设置线程的优先级,取值是1到10。

守护线程

守护线程也被称为后台线程,它是低优先级的,一般用于服务其他线程,比如GC线程就是一个守护线程。并且JVM判断程序执行结束的标准是所有的前台执线程行完毕了,而不管后台线程的状态。

可以通过setDaemon(true)方法来将一个线程设置为守护线程。

悲观锁和乐观锁

  • **悲观锁:**悲观锁是一种锁的思想,指每次访问共享资源时都会和其他线程发生竞争和冲突,因此,悲观锁思想认为在每次访问共享资源时都采取“先加锁,再操作,最后释放锁”的策略,以确保在操作期间其他线程不能修改共享资源。悲观锁适用于读操作相对较少、写操作频繁的场景。
  • **乐观锁:**乐观锁也是一种锁的思想,指每次访问共享资源时都认为不会和其他线程发生竞争和冲突,所以不会进行加锁操作,但是当线程执行写操作时,它会先读取共享资源的版本信息(如时间戳或版本号),然后在更新时比较版本信息是否发生了变化。如果在此期间没有其他线程修改了共享资源,那么操作会成功;否则,需要重新尝试。乐观锁适用于读操作频繁、写操作相对较少的场景。

公平锁与非公平锁

  • 公平锁:多个线程按照申请锁的顺序来获取锁。
  • 非公平锁:多个线程获取锁的顺序并不是按照申请锁的顺序,有可能后申请的线程比先申请的线程优先获取锁。有可能会造成优先级反转或者饥饿现象。

共享锁与互斥锁

  • 共享锁:允许同时被多个线程读,一般用作读锁。
  • 互斥锁:一次只能被一个线程所持有,一般用作写锁。

读写锁原则:读时能读,读时不能写,写时不能写,写时不能读。

可重入锁和自旋锁

  • 可重入锁:可重入锁指的是已经获取到锁的线程可以再次获取这个锁,并不会发生死锁,需要注意的是获取了多少次锁就必须要释放多少次。
  • 自旋锁:自旋锁指的是在某个线程尝试去获取锁时,如果没有获取到锁,那么线程不会立即阻塞,而是采用循环的方式去尝试获取锁,这样的好处是减少线程上下文切换的消耗,缺点是循环会消耗CPU。

可中断锁和不可中断锁

  • 可中断锁:可以响应中断的锁。假如A线程持有锁,B线程等待获取该锁。B线程不想等了,可以在B线程中断自己或者在别的线程中断B线程。比如Lock:lock.lockInterruptibly()尝试获取锁,无论起因等锁而阻塞还是获取到锁后再执行,均可响应其它线程的interrupt。
  • 不可中断锁:不可以响应中断的锁。例如synchronized。

活锁与死锁

  • 活锁:任务没被阻塞,由于某些条件没满足,导致一直在失败与重复尝试过程。处于活锁的实体是在不断的改变状态, 活锁有可能自行解开。
  • 死锁:两个或多个线程互相等待对方持有的资源而无法继续执行。死锁不会自行解开。

下面通过一段代码来说明线程死锁:

public class DeadLockDemo {
   private static Object resource1 = new Object();//资源 1
   private static Object resource2 = new Object();//资源 2

   public static void main(String[] args) {
       new Thread(() -> {
           synchronized (resource1) {
               System.out.println(Thread.currentThread() + "get resource1");
               try {
                   Thread.sleep(1000);
              } catch (InterruptedException e) {
                   e.printStackTrace();
              }
               System.out.println(Thread.currentThread() + "waiting get resource2");
               synchronized (resource2) {
                   System.out.println(Thread.currentThread() + "get resource2");
              }
          }
      }, "线程 1").start();

       new Thread(() -> {
           synchronized (resource2) {
               System.out.println(Thread.currentThread() + "get resource2");
               try {
                   Thread.sleep(1000);
              } catch (InterruptedException e) {
                   e.printStackTrace();
              }
               System.out.println(Thread.currentThread() + "waiting get resource1");
               synchronized (resource1) {
                   System.out.println(Thread.currentThread() + "get resource1");
              }
          }
      }, "线程 2").start();
  }
}

输出结果:

Thread[线程 1,5,main]get resource1
Thread[线程 2,5,main]get resource2
Thread[线程 1,5,main]waiting get resource2
Thread[线程 2,5,main]waiting get resource1

可以看到两个线程都在等待获取对方所持有的资源,从而导致无法再继续运行。

死锁的四个必要条件

  • 互斥条件:线程对于所分配到的资源具有排它性,即一个资源只能被一个线程占用,直到被该线程释放。
  • 不剥夺条件:线程已获得的资源在末使用完之前不能被其他线程强行剥夺,只有自己使用完毕后才释放资源。
  • 请求与保持条件:一个线程因请求被占用资源而发生阻塞时,对已获得的资源保持不放。
  • 循环等待条件:当发生死锁时,所等待的线程必定会形成一个环路(类似于死循环),造成永久阻塞。

如何避免死锁

避免死锁只需要打破一个或多个死锁的必要条件就可以了。

1、破环互斥条件

互斥条件无法破坏,因为本来加锁就是为了让线程互斥的。

2、 破环不剥夺条件

可以使占用部分资源的线程进一步申请其他资源时,如果申请不到,可以主动释放它占有的资源。

3、 破坏请求与保持条件

一次性申请所有的资源,会降低并发能力,一般不破坏请求与保持条件。

4、破坏循环等待条件

规定所有线程按某一顺序申请资源,释放资源则反序释放。

我们将刚才死锁的代码案例稍微修改:

public class DeadLockDemo {
   private static Object resource1 = new Object();//资源 1
   private static Object resource2 = new Object();//资源 2

   public static void main(String[] args) {
       new Thread(() -> {
           synchronized (resource1) {
               System.out.println(Thread.currentThread() + "get resource1");
               try {
                   Thread.sleep(1000);
              } catch (InterruptedException e) {
                   e.printStackTrace();
              }
               System.out.println(Thread.currentThread() + "waiting get resource2");
               synchronized (resource2) {
                   System.out.println(Thread.currentThread() + "get resource2");
              }
          }
      }, "线程 1").start();

     // 将线程2的获取资源顺序改为和线程1一致
     new Thread(() -> {
       synchronized (resource1) {
           System.out.println(Thread.currentThread() + "get resource1");
           try {
               Thread.sleep(1000);
          } catch (InterruptedException e) {
               e.printStackTrace();
          }
           System.out.println(Thread.currentThread() + "waiting get resource2");
           synchronized (resource2) {
               System.out.println(Thread.currentThread() + "get resource2");
          }
      }
    }, "线程 2").start();
  }
}

输出结果:

Thread[线程 1,5,main]get resource1
Thread[线程 1,5,main]waiting get resource2
Thread[线程 1,5,main]get resource2
Thread[线程 2,5,main]get resource1
Thread[线程 2,5,main]waiting get resource2
Thread[线程 2,5,main]get resource2

这里我们通过破坏循环等待条件来避免了死锁的发生。

synchronized

基本使用

synchronized是Java中的一个关键字,它是锁思想的一种具体实现,synchronized是一个非公平的可重入的互斥锁,并且会自动释放锁。

synchronized用法如下:

  • 修饰方法
    • 修饰实例方法:锁的是实例对象。当一个线程已获取对该对象的对象锁后,其它线程都需阻塞并等待。
    • 修饰静态方法:锁的是类对象(Class对象)。当一个线程已获取对该类的类锁后,其它线程都需阻塞并等待。
  • 修饰代码块
    • 锁当前对象:效果同锁实例方法一致。例如synchronized (this) {}。
    • 锁类对象:效果同锁静态方法一致。例如synchronized (Object.class) {}。
    • 锁任意实例对象:效果同锁实例方法一致。例如synchronized (new Object()) {}。

各种用法具体实现代码如下:

public class SynchronizedTest {
    public static final Object lock = new Object();

    /**
     * 作用于实例方法,锁的是实例对象
     */
    public synchronized void testSynchronized_1() {

    }

    /**
     * 作用于静态方法,锁的是类对象
     */
    public synchronized static void testSynchronized_2() {

    }

    /**
     * 作用于代码块,锁的是当前对象
     */
    public void testSynchronized_3() {
        synchronized (this) {

        }
    }

    /**
     * 作用于代码块,锁的是类对象
     */
    public void testSynchronized_4() {
        synchronized (SynchronizedTest.class) {

        }
    }

    /**
     * 作用于代码块,锁的是实例对象
     */
    public void testSynchronized_5() {
        synchronized (lock) {

        }
    }

}

synchronized锁升级和撤销原理

在了解synchronized的具体实现原理之前,我们先来了解一下对象头中的Mark Word、Lock Record和ObjectMonitor。

Mark Word

下图是64位虚拟机中的对象头的Mark Word结构:

在这里插入图片描述

Lock Record

Lock Record结构如下:

class BasicObjectLock VALUE_OBJ_CLASS_SPEC {
    private:
    BasicLock _lock;
    oop       _obj; // 指向锁对象。
};
class BasicLock VALUE_OBJ_CLASS_SPEC {
    private:
    volatile markOop _displaced_header; // 用于轻量级锁中暂存锁对象的markword,也称为 displaced mark word。
};

1.当执行monitorenter字节码锁住一个对象时,就会在获取锁的线程的栈上显式或者隐式分配一个lock record。

2.实现锁重入的计数器,当每次锁重入时,会用一个Lock Record来记录,但是此时_displaced_header为null。每解锁1次,就移除1个Lock Record。移除时,判断_displaced_header是否为null。如果是,则代表是锁重入,则不会执行真正的解锁;否则,代表这是最后一个 Lock Record,此时会真正执行解锁操作。

ObjectMonitor

ObjectMonitort是整个synchronized的核心,它是由c++写的。

下面是ObjectMonitor的结构:

ObjectMonitor() {
    _header = NULL; // 对象头的mark word 
    _count = 0; 
    _waiters = 0; // 等待线程数
    _recursions = 0; // 线程的重入次数
    _object = NULL; // 对应synchronized(object)对应里面的object
    _owner = NULL; // 标识拥有该monitor的线程
    _WaitSet = NULL; // 因为调用object.wait()方法而被阻塞的线程会被放在该队列中
    _WaitSetLock = 0 ;
    _Responsible = NULL;
    _succ = NULL;
    _cxq = NULL; // 竞争队列,所有请求锁的线程首先会被放在这个队列中
    FreeNext = NULL;
    _EntryList = NULL; // 阻塞队列,第二轮竞争锁仍然没有抢到的线程(在exit之后扔没有竞争到的线程将有可能会被同步至此)
    _SpinFreq = 0;
    _SpinClock = 0;
    OwnerIsThread = 0;
}
wait、notify、notifyAll的实现

1、如果一些个线程获取到锁,那么就会执行同步代码块的代码,没有获取到锁的线程就会被封装成一个ObjectWaiter,然后根据策略将ObjectWaiter放入cxq或者EntryList链表中。

2、如果获取到锁的线程,在同步代码块中调用了wait方法,那么当前线程将会被封装成ObjectWaiter然后加入到WaitSet队列的尾部,并且会释放自己所持有的锁资源。

3、当其他线程获取到锁,在同步代码块中调用了notify方法,会将WaiSet队列的第一个节点唤醒,并且根据策略将其加入到cxq或者EntryList的头部或尾部。

4、notifyAll就是对所有WatiSet中的节点执行notify方法唤醒。

偏向锁

偏向锁执行流程如下图所示:

在这里插入图片描述

偏向锁加锁

1.将Lock Record的obj属性指向当前锁对象。

2、如果锁对象的锁标志位是01,并且偏向锁位是1,那么就通过CAS操作在对象的Mark Word中记录当前线程的线程ID,如果成功就获取到偏向锁。

3、如果成功获取到偏向锁,那么后续每次重入只需要判断Mark Word中的线程ID是否是自己,如果是就直接进入同步代码块。

偏向锁撤销

1、等到竞争出现才释放锁,所以当其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁。

2、尝试获取偏向锁的线程会像原持有偏向锁的线程发起一个暂停请求,等到了全局安全点,判断原持有锁的线程是否还处于同步代码块中。若还处于同步代码块中,则升级为轻量级锁。

3、如果原持有偏向所的线程没有在徒步代码块中或者已经不处于存活状态,那么将Mark Word设置为无锁状态。

轻量级锁
轻量级锁加锁

1、将Lock Record的obj属性指向当前锁对象。
2、将锁对象的Mark Word填充到Lock Record的displaced_header属性。
3、使用CAS将对象头的markword修改为指向Lock Record,成功则加锁完成,锁相关标志设置为00。
4、失败则代表有其它线程竞争,当前线程便会开始自旋获取锁。多次失败则升级为重量级锁,锁标志设置为10。

轻量级锁解锁

1、将Lock Record的obj属性赋值为null。
2、使用CAS将displaced_header属性暂存的锁对象的Mark Word还原到锁对象的Mark Word上。
3、CAS成功则释放成功,失败则说明有其他线程尝试获取该锁,升级为重量级锁,锁相关标志设置为10。

重量级锁
重量级锁加锁

1、分配一个ObjectMonitor,并填充相关属性。
2、CAS将对象头的markword修改为指向ObjctMonitor,成功则获取到锁,锁相关标志设置为10。如果重复获取锁则recursions属性+1
3、如果CAS失败,则将该线程封装成ObjectWaiter,插入到ObjectMonitor的cxq链表中,当前线程进入阻塞状态。
4、当其它锁释放时,会根据Qmode的策略唤醒cxq或EntryList链表中的节点,被唤醒的节点会再次尝试获取锁,获取成功后,将自己从链表中移除。

重量级锁解锁

1、将重入计数器-1,ObjectMonitor里的recursions属性,减到0才代表真正准备释放锁。
2、将锁的持有者owner属性赋值为null,此时其他线程已经可以获取到锁。
3、会根据Qmode的策略唤醒cxq或EntryList链表中的节点,当作successor,successor会尝试获取锁。

synchronized实现原理

下面我们通过两段代码来展示synchronized到底是如何实现加锁的。

public static void main(String[] args) {

    synchronized (Object.class) {
        System.out.println("hello world!");
    }

}

上面的代码是一段很简单的同步代码块使用案例,接下来我们将这段代码使用javap -v命令进行反编译:

在这里插入图片描述

monitorenter指令:代表开始进入同步代码块,获取到锁。

第一个monitorexit指令:同步代码块执行完毕,释放锁。

第二个monitorexit指令:在发生异常时释放锁。

下面我们再来在看一下同步方法是如何实现的,下面是一个简单的同步方法的代码案例:

public synchronized static void hello() {
    System.out.println("hello world!");
}

使用javap -v命令进行反编译之后:

在这里插入图片描述

ACC_SYNCHRONIZED访问标志:当方法调用时,首先会检查方法ACC_SYNCHRONIZED标识是否被设置,如果设置了,那么执行线程首先会尝试获取monitor锁,然后再执行方法,最后在方法完成后再释放monitor锁(不管是正常结束还是异常退出都会释放)。

AbstractQueuedSynchronizer(AQS)

AQS基本概念

AbstractQueuedSynchronizer简称AQS,它是一个抽象队列同步器,可以用于构建锁、同步器、协作工具类的工具类。例如ReentrantLock、CountDownLatch、Semaphore等等都是基于AQS实现。

AQS关键数据结构

  • **state:**同步状态,一个由volatile修饰的int类型的变量,并且提供了getsetcompareAndSetState三个方法来操作这个变量。根据AQS的具体实现类不同,而具有不同的含义,比如在ReentrantLock中代表获取锁的次数,在Semaphore里表示剩余的许可证数量等。
  • **head:**指向条件队列的头节点
  • **tail:**指向条件队列的尾节点
  • **Node:**一个内部类,将线程封装成Node节点放入同步队列或条件队列中
    • **prev:**同步队列上一节点。
    • **next:**同步队列下一节点。
    • **thread:**当前线程。
    • **nextWaiter:**条件队列的下一节点。
    • **SHARED:**标记当前Node是共享模式。
    • **EXCLUSIVE:**标记当前Node是独占模式。
    • **waiteStatus:**只会有五个取值,默认为0,表示初始状态,CANCELLED(等待的线程等待超时或者被中断),SIGNAL(后继节点处于park,需要唤醒),CONDTION(节点在等待队列中,被signal后,会加入到同步队列的队尾),PROPAGATE(下一次共享模式同步状态获取将会无条件地被传播下去)。
  • **ConditionObject:**条件对象,只有ReentrantLock和ReentrantReadWriteLock会使用到
    • **firstWaiter:**条件队列的头节点。
    • **lastWaiter:**条件队列的尾节点。
  • **exclusiveOwnerThread:**当前持有锁的线程,仅在独占模式下拥有。该变量在AQS的父类AbstractOwnableSynchronizer中。
  • **同步队列:**双向链表,基于CLH队列的变种,是一个虚拟的队列(指没有具体的队列实体,只有节点与节点之间的关系),由Node节点实现,获取锁失败的线程或被封装成Node节点然后放入同步队列尾部。
  • **条件队列:**单向链表,也可以称之为等待队列,在调用await方法之后会将线程封装成Node节点然后放入条件队列的尾部,并且线程会释放自己所持有的锁资源。条件队列可以有多个。

同步队列和条件队列流转流程如下图所示:

在这里插入图片描述

锁模式

  • **共享模式:**同一时刻允许多个线程协同操作state变量。
  • **独占模式:**同一时刻只能有一个线程操作state变量。
独占锁获取(acquire)

1、调用tryAcquire方法尝试获取锁,由具体的实现类实现,若获取成功则直接返回。

2、如果tryAcquire获取锁失败,那么调用addWaiter方法将当前线程封装成Node节点加入到同步队列尾部,Node的nextWaiter设置为EXCLUSIVE。

3、调用acquireQueued方法,如果该线程节点的前驱节点是头节点,调用tryAcquire方法再给该线程一次争用线程的机会,成功则返回。若前驱不是头节点或争用线程失败。将当前节点的上一节点waitStatus设置为SIGNAL。

4、调用LockSupport.park(this)阻塞当前线程。

独占锁释放(release)

1、调用tryRelease方法尝试释放锁,由具体的实现类实现,若锁完全释放才返回成功。

2、将头节点的waitStatus设置为0,调用LockSupport.unpark(s.thread)唤醒同步队列头节点的下一节点。

可中断式独占锁获取(acquireInterruptibly)

整体和acquire类似,区别如下:

1、在最开始,若当前线程已被中断,则抛出InterruptedException。

2、只是parkAndCheckInterrupt内部的LockSupport.park(this);线程恢复后调用Thread.interrupted()返回true,代表该线程被其它线程中断,则外层抛出InterruptedException。

超时等待独占锁获取(tryAcquireNanos)

和acquireInterruptibly类似,区别如下:

1、只是增加了deadline,并且使用LockSupport.parkNanos(this, nanosTimeout);使得当前线程阻塞一定时间,恢复后会再尝试获取锁,如果成功就返回true,如果还是获取锁失败就判断时间是否超过了deadine,超过了就会返回false。

共享锁获取(acquireShared)

1、调用tryAcquireShared方法尝试获取共享锁,由具体的实现类实现,只有当返回小于0时代表获取锁失败,返回大于等于0代表获取锁成功。

2、如果获取失败会调用doAcquireShared方法,就将当前线程封装成一个Node节点,并且添加到同步队列尾部,Node的nextWaiter设置为SHARED。

3、加入到同步队列尾部之后还会再次尝试获取锁,如果获取成功就直接返回,获取失败就调用LockSupport.park(this)阻塞当前线程。

共享锁释放(releaseShared)

1、调用tryReleaseShared方法尝试释放共享锁,由具体的实现类实现,如果释放失败返回false。

2、如果释放成功,那么会调用doReleaseShared方法,这个方法会判断头节点的状态,如果是SIGNAL那么就唤醒头节点的下一个节点,如果是0,那么就将头节点的waitStatus设置为PROPAGATE。

可中断共享锁获取(acquireSharedInterruptibly)

和acquireInterruptibly类似,区别如下:

1、创建的Node节点里nextWaiter属性为SHARED。

超时等待(tryAcquireSharedNanos)

和tryAcquireNanos类似,区别如下:

1、创建的Node节点里nextWaiter属性为SHARED。

await流程

1、如果线程中断则抛出InterruptedException异常。

2、将当前线程封装成Node节点,放入条件队列的尾部。

3、调用fullyRelease方法释放所有已持有的锁资源。

signal流程

1、将条件队列的头节点状态设置为0。

2、调用enq方法将节点移动到同步队列尾部,并且会返回当前节点的前驱节点。

3、判断前驱节点是否被取消,或者使用CAS将前驱节点的waitStatus设置为SIGNAL失败,则唤醒当前线程

signalAll流程

单个的流程与signal类似,区别如下:

1、从条件队列的对头开始,将每个节点都移动到同步队列的队尾去,即都调用transferForSignal方法。

wait/notify与await/signal的区别
wait/notifyawait/signal
前置条件获取到对象锁获取到lock锁
所属类Object类AQS的ConditionObject类
等待队列个数1个多个
是否响应中断不可以响应中断可以响应中断
是否可以等到到某个时间不可以可以

ReentrantLock与ReentrantReadWriteLock

ReentrantLock与ReentrantReadWriteLock都是基于AQS实现,他们内部都拥有一个Sync内部类,Sync继承了AQS来获的AQS的同步能力,而两个静态内部类FairSync与NonfairSync都继承了Sync用来实现公平锁和非公平锁。

ReentrantLock

ReentrantLock默认非公平,可以通过public ReentrantLock(boolean fair)构造函数传入true来构建一个公平锁。

公平锁和非公平锁实现原理
  • **公平锁:**公平锁在获取锁的时候多了一个hasQueuedPredecessors方法。该方法会判断当前节点的是否还有前驱节点。只有当没有前驱节点时才能获取锁,这样就保证了公平锁的时间顺序,增加了上下文切换。
  • **非公平锁:**非公平锁是指在线程执行完同步代码之后唤醒下一个节点,但是被唤醒的节点并不是直接获取到锁资源,而是再尝试去抢锁,这个时候也可能是由其他线程获取到锁。可能会造成线程饥饿现象。
可重入锁实现原理
  • **加锁:**如果锁已经被占用,就判断持有锁的线程是不是当前线程,如果是那么就将state+1并返回true。
  • **解锁:**每次释放锁state-1,当state=0时,才返回true,并把AbstractOwnableSynchronizer的exclusiveOwnerThread置为null,否则false。
synchronized和ReentrantLock的区别
synchronizedReentrantLock
底层实现关键字,是JVM层面的锁类,JDK层面的锁,基于AQS
是否需要手动释放锁不需要需要
公平性非公平可指定公平或者非公平,默认非公平
灵活性获取不到锁就一直等待,直到获取到锁有立即返回是否成功的,有响应中断、有超时时间等
是否可响应中断不可以可以

ReentrantReadWriteLock

ReentrantReadWriteLock除了拥有Sync、FairSync、NonFairSync这三个内部类,还拥有ReadLock和WriteLock这两个静态内部类来实现读锁和写锁,这两个类都实现了Lock接口,并且都有一个Sync的成员变量。

读写锁的特性
特性描述
公平性选择支持非公平(默认)和公平的锁获取方式,吞吐量还是非公平优于公平。
是否可重入读锁和写锁都支持线程重入。读线程获取读锁后,能够再次获取读锁。写线程在获取写锁之后能够再次获取写锁,同时也可以获取读锁。
锁降级遵循获取写锁、再获取读锁最后释放写锁的次序,写锁能够降级成为读锁。
写锁获取锁

1、通过exclusiveCount方法获取写锁获取的次数,通过state的值和写锁的次数就可以知道是否有读锁。

2、如果state等于0表示资源没有被占用,直接使用CAS尝试获取锁,成功就将exclusiveOwnerThread设置为当前线程id。

3、如果state不等于0,那么判断如果写锁的获取次数为0(代表都是读锁资源)或者持有锁的线程不是当前线程那么获取锁失败,否则将state的值+1。

写锁释放锁

1、通过exclusiveCount方法查看本次释放锁之后的写锁数量是否为0。

2、如果为0那么代表写锁释放完毕,将exclusiveOwnerThread设置为null,返回true。

3、如果不为0,那么代表写锁还没有完全释放完毕,重新设置一下state的值,返回false。

读锁获取锁

1、当写锁被其它线程获取后,读锁获取失败,返回-1。

2、如果没有其他线程持有写锁,那么使用CAS更新state,获取成功返回1。

读锁释放锁

1、用CAS将state减少SHARED_UNIT。

读写锁的升级和降级
  • **升级:**不支持锁升级:在线程持有读锁的情况下,该线程不能取得写锁,因为可能还有其他线程正在持有读锁。
  • **降级:**支持降级: 持有写锁的情况下可以去获取读锁,然后释放写锁就只持有读锁,形成了写锁降级为读锁。

并发工具类(JUC)

常用的并发工具类有以下几种:

  • CountDownLatch(倒计时锁/闭锁)
  • Semaphore(信号量)
  • CyclicBarrier(循环栅栏)

CountDownLatch

实现原理

1、通过构造函数传入一个count,然后把count设置为AQS的state,代表倒数的数量。

2、调用CountDownLatch的await,也就是AQS可中断共享的锁获取方法,阻塞当前线程。

3、当其他线程调用countDown,state就会-1。当state=0了,就代表锁释放成功了(由CountDownLatch实现),唤醒了AQS中头部节点的下一节点,也就是调用await的线程节点。

**注意:**需要注意的是如果count传入的是0,那么不会阻塞调用await方法的线程。

使用场景

多个子线程并行运算,主线程等待其运算结果。

Semaphore

实现原理

1、通过构造函数传入一个permits,然后把permits设置为AQS的state,代表剩余许可数量。

2、调用acquire获取共享锁,当state>0时,获取共享锁成功,此时state=state-1。当剩余state < 0时,获取共享锁失败。线程进入同步队列后阻塞。

3、调用release方法,也就是调用AQS中的releaseShared方法,释放同步锁。

使用场景

需要控制并发数量的地方。

CyclicBarrier

实现原理

1、初始化CyclicBarrier,CyclicBarrier(int parties, Runnable barrierAction),parties表示屏障拦截的线程数,barrierAction表示当所有线程都到达屏障时,会执行该线程。

2、拦截点使用await方法,调用后阻塞。

3、当parties数量的线程都到await方法后,先执行barrierAction,然后线程又可以运行了。

使用场景

所有线程都到达某一状态后,才能继续运行。

CountDownLatch与CyclicBarrier的区别
  • CountDownLatch计数器只能用一次;CyclicBarrier计数器可以使用reset方法重置,若此时有线程处于await状态,则会抛出BrokenBarrierException异常。

  • CountDownLatch用于调用await的线程等待其它线程;CyclicBarrier用于一组线程互相等待。

  • CountDownLatch方法较少;CyclicBarrier还有getNumberWaiting用于获取阻塞线程数。isBroken了解阻塞的线程是否被中断。

总体来说CyclicBarrier比CountDownLatch的功能更多更强大。

Unsafe

Unsafe是位于sun.misc包下的一个类,主要提供一些用于执行低级别、不安全操作(不安全是指Unsafe类所做的操作不受jvm管理的,不会被GC)的方法,如直接访问系统内存资源、自主管理内存资源等,这些方法在提升 Java 运行效率、增强 Java 语言底层资源操作能力方面起到了很大的作用。但是Unsafe提供的方法具体实现基本都是依赖于本地方法。

获取Unsafe实例

由于Unsafe类的构造器被private修饰,所以不能够直接通过它的构造函数创建Unsage实例。而Unsafe提供了一个getUnsafe静态方法可以来获取Unsafe实例,但是该方法会对调用者的ClassLoader进行检查,判断当前类是否由BootstrapClassLoader加载,如果不是的话那么就会抛出一个SecurityException异常。

所以根据限制条件,只有以下两个方法可以获取到Unsafe实例:

  • 通过反射获取Unsafe类中已经实例化好的静态变量theUnsafe,推荐使用。

    public static void main(String[] args) {
        try {
            Field field = Unsafe.class.getDeclaredField("theUnsafe");
            field.setAccessible(true);
            Unsafe unsafe = (Unsafe) field.get(null);
    
            // do something
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
    
  • 通过-Xbootclasspath将要创建Unsafe实例的类交给BootstrapClassLoader类加载器加载。

    // 其中path为调用Unsafe相关方法的类所在jar包路径
    // A代表创建Unsafe实例的类名
    java -Xbootclasspath/A: ${path}   
    

Unsafe作用

Unsafe的作用如下:

  • 内存操作:Java不允许直接对内存进行操作的,对象内存的分配和回收都是由 JVM 自己实现的。而Unsafe提供了可以直接操作内存的方法。

  • 内存屏障:Unsafe提供了三个内存屏障相关的方法。

  • 对象操作:Unsafe 提供了全部 8 种基础数据类型以及Objectputget方法,并且所有的put方法都可以越过访问权限,直接修改内存中的数据。

  • 数据操作:提供了arrayBaseOffset 与 arrayIndexScale`这两个方法配合起来使用,即可定位数组中每个元素在内存中的位置。

  • CAS操作:在 Unsafe类中,提供了compareAndSwapObject、compareAndSwapInt、compareAndSwapLong方法来实现的对Object、int、long类型的 CAS 操作。

  • 线程调度:Unsafe类中提供了park、unpark、monitorEnter、monitorExit、tryMonitorEnter方法进行线程调度。

  • Class操作:Unsafe对Class的相关操作主要包括类加载和静态变量的操作方法。

  • 系统信息:Unsafe提供了addressSize方法获取系统指针的大小,返回值为4(32位系统)或 8(64位系统)。pageSize方法获取内存页的大小,此值为2的幂次方。

注意:其中的具体实现和使用方式可以移步到https://javaguide.cn/java/basis/unsafe.html查看

volatile

在学习volatile之前,我们回顾一下并发的三特性为:原子性、可见性、有序性,具体的说明在JMM篇章我们已经说明过,这里就不再赘述了。

volatile关键字的作用

volatile关键字有两个作用:

  • **保证可见性:**当线程A在线程A的工作内存中修改了volatile变量,会立即将修改后的值更新到主存中,线程B就会让线程B的工作内存中的这个volitile变量失效,重新从主存中获取这个变量。
  • **禁止指令重排序:**对volatile修饰的关键字进行操作时,能保证在其前面的操作都执行完成,也能保证在其后的操作都没有执行。

**注意:**volatile关键字并不能保证原子性。例如:对一个volatile关键字修饰的int变量进行自增操作就是不安全的。

内存屏障(MemoryBarrier)

内存屏障(MemoryBarrier)也叫内存栅栏,内存屏障有两个作用,一是保证特定操作的执行顺序,二是保证某些变量的内存可见性。volatile的实现就是基于内存屏障实现的,所以了解了内存屏障也就明白了volatile是如何保证可见性和禁止指令重排序的了。

由于编译器和处理器都能执行指令重排优化,如果在指令间插入一条MemoryBarrier,则会告诉编译器和CPU,不管什么指令都不能和这条MemoryBarrier指令重排序,也就是通过插入内存屏障,禁止在内存屏障前后的指令执行重排序优化。

在JMM层面有以下几种内存屏障:

屏障类型指令示例说明
LoadLoad BarriersLoad1;LoadLoad;Load2;确保Load1指令数据的装载,发生于Load2及后续所有装载指令的数据装载之前。
StoreStore BarriersStore1;StoreStore;Store2确保Store1数据的存储对其他处理器可见(刷新到内存中),并发生于Store2及后续所有存储指令的数据写入之前。
LoadStore BarriersLoad1;LoadStore;Store2确保Load1指令数据的装载,发生于Store2及后续所有存储指令的数据写入之前。
StoreLoad BarriersStore1;StoreLoad;Load2确保Store1数据对其它处理器可见(刷新到内存)并且先于Load2及其后序装载指令的装载。它会使该屏障之前的所有内存访问指令(存储指令和访问指令)完成之后,才执行该屏障之后的内存访问指令

Java编译器在生成指令序列的适当位置,会插入内存屏障指令来禁止特定类型的处理器重排序,从而让程序按我们预想的流程去执行。

JMM把内存屏障指令分为4类,StoreLoad Barriers是一个“全能型”的屏障,它同时具有其他3个屏障的效果。现代的多处理器大多支持该屏障(其他类型的屏障不一定被所有处理器支持)

Volatile实现原理

  • **读volatile变量:**在读一个volatile变量时,JMM会把该线程对应的本地内存置为无效。线程接下来将从主内存中读取共享变量。
  • **写volatile变量:**在写一个volatile变量时,JMM会把该线程对应的本地内存中的共享变量值刷新到主内存。

双重检测单例模式代码案例

代码案例如下:

public class Singleton {
    // 这里的instance没有用volatile关键字修饰
    private static Singleton instance;

    private Singleton() {
    }

    public static Singleton getInstance() {
        if (instance == null) {
            synchronized (Singleton.class) {
                if (instance == null) {
                    instance = new Singleton();
                }
            }
        }
        return instance;
    }

}

上述代码是一个经典双重检测的单例模式,这段代码在单线程环境下并没有什么问题,可是如果在多线程环境下,就可能出现线程安全问题。原因在于某一个线程执行到第一次检测,读取到的singleton不为null时,singleton的引用对象可能没有完成初始化。

因为instance = new Singleton();可以分为以下3步完成(伪代码):

// 1、分配对象内存空间
memory = allocate();

// 2、初始化对象
singleton(memory); 

// 3、设置singleton指向刚分配的内存地址,此时singleton != null
singleton = memory;

而这几步操作之间可能会发生重排序,如果重排序之后执行顺序变成1、3、2,那么当线程A执行完1、3步之后还没有执行步骤2,这个时候线程B刚好进来执行第一个为空判断时发现instance已经不为空了,就直接返回了instance的实例,但是实际上这个时候instance还没有初始化。

那么解决方案就是将instance变量加上volatile关键字来保证变量修改的可见性和禁止指令重排序,修改后的代码如下:

public class Singleton {
    // 使用volatile关键字修饰
    private static volatile Singleton instance;

    private Singleton() {
    }

    public static Singleton getInstance() {
        if (instance == null) {
            synchronized (Singleton.class) {
                if (instance == null) {
                    instance = new Singleton();
                }
            }
        }
        return instance;
    }

}

Atomic原子类

在JDK1.5版本新增的java.util.concurrent.atomic包下提供了很多原子工具类,这些工具类提供的单个操作都是原子性的,而这些原子类实现原子性操作的方案则是基于CAS来实现的。

CAS(Compare And Swap)

实现原理

CAS是乐观锁的一种实现,它有三个操作数,分别是内存地址V、预期值A、更新值B,具体流程为只有当预期值A与内存地址V中的值一致时,就将内存地址V中的值更新为B,否则什么都不做。这一整个比较并切换是一个原子操作。

CAS在Java中的实现

CAS在Java中的实现是通过Unsafe类提供的本地方法实现的。

代码如下:

/**
 * 参数说明:
 * 	第一个参数:要修改的对象
 *	第二个参数:内存地址偏移量
 *	第三个参数:预期原值
 *	第四个参数:更新值
 */
public final native boolean compareAndSwapObject(Object var1, long var2, Object var4, Object var5);

public final native boolean compareAndSwapInt(Object var1, long var2, int var4, int var5);

public final native boolean compareAndSwapLong(Object var1, long var2, long var4, long var6);
CAS缺点以及解决方案

1、只能保证一个变量的原子操作。

2、循环时间长开销大:对于资源竞争严重(线程冲突严重)的情况,CAS 自旋的概率会比较大,从而浪费更多的 CPU 资源,效率低于 synchronized。

3、ABA问题:指当线程1想去修改内存地址V中的值时,如果获取到的预期原址为A,但是还没有进行CAS更新操作,这个时候线程1的时间片用完了,由线程2获取到了CPU的时间片开始执行,线程2将内存地址V中的预期值A先修改为了B,然后又改回为了A,再当线程1执行时发现内存地址V中的值与预期值A一样,所以直接修改成功,但是实际上这个值在期间已经发生过变动。在特定情况下可能会导致不可预知的问题。

ABA问题解决方案

在每次修改时添加一个版本号或者时间戳,后续使用CAS更新不止要判断内存地址V中的值和预期原值是否相同,还要判断版本号或者时间戳是否变动过,如果二者都相同,那么说明这个值没有被修改过。在Atomic包下也提供了AtomicStampedReference来解决ABA问题。

ThreadLocal

ThreadLocal是指线程本地变量,它是一种空间换时间的思想,每个线程中都有一个ThreadLocal.ThreadLocalMap类型的变量threadLocals,存储在ThreadLocal中的变量为线程私有的。

下面通过一个代码案例来说明:

public class ThreadLocalTest {
    private ThreadLocal<String> local = new ThreadLocal<>();

    public void printName() {
        String name = Thread.currentThread().getName();
        System.out.println(name + ", before set: " + local.get());
        local.set(name);
        System.out.println(name + ", after set:" + local.get());
    }

    public static void main(String[] args) {
        ThreadLocalTest test = new ThreadLocalTest();

        // 创建几个线程同时执行printName方法
        new Thread(test::printName, "Thread-001").start();
        new Thread(test::printName, "Thread-002").start();
        new Thread(test::printName, "Thread-003").start();

    }

}

运行结果如下所示:

Thread-001, before set: null
Thread-002, before set: null
Thread-002, after set:Thread-002
Thread-001, after set:Thread-001
Thread-003, before set: null
Thread-003, after set:Thread-003

可以看到虽然几个线程都是操作的同一个变量local,但是都互不影响。

整体实现原理

Thread类有一个类型为ThreadLocal.ThreadLocalMap的实例变量threadLocals,也就是说每个线程有一个自己的ThreadLocalMap。

ThreadLocalMap有自己的独立实现,可以简单地将它的key视作ThreadLocal,value为代码中放入的值(实际上key并不是ThreadLocal本身,而是它的一个弱引用)。

每个线程在往ThreadLocal里放值的时候,都会往自己的ThreadLocalMap里存,读也是以ThreadLocal作为引用,在自己的map里找对应的key,从而实现了线程隔离

ThreadLocalMap有点类似HashMap的结构,只是HashMap是由数组+链表实现的,而ThreadLocalMap中并没有链表结构。

ThreadLocal源码分析

get方法源码分析
public T get() {
    // 获取当前线程
    Thread t = Thread.currentThread();
    // 获取当前线程的ThreadLocalMap
    ThreadLocalMap map = getMap(t);
    if (map != null) {
        // 通过当前的ThreadLocal获取Entry
        ThreadLocalMap.Entry e = map.getEntry(this);
        if (e != null) {
            @SuppressWarnings("unchecked")
            // 返回Entry中的值
            T result = (T)e.value;
            return result;
        }
    }
    // 如果没有获取到map那就设置一个初始值并返回
    return setInitialValue();
}


// 获取当前线程的ThreadLocalMap
ThreadLocalMap getMap(Thread t) {
    return t.threadLocals;
}


// 通过当前的ThreadLocal获取Entry
private Entry getEntry(ThreadLocal获取<?> key) {
    int i = key.threadLocalHashCode & (table.length - 1);
    Entry e = table[i];
    if (e != null && e.get() == key)
        return e;
    else
        return getEntryAfterMiss(key, i, e);
}

// 设置并返回初始值
private T setInitialValue() {
    // 初始化值,ThreadLocal中返回的null
    T value = initialValue();
    Thread t = Thread.currentThread();
    ThreadLocalMap map = getMap(t);
    if (map != null)
        map.set(this, value);
    else
        // 如果map为null则创建一个map,并设置初始值
        createMap(t, value);
    return value;
}

1、获取当前线程中ThreadLocalMap,ThreadLocalMap的key为ThreadLocal的弱引用

2、如果ThreadLocalMap不为空,那么通过当前的ThreadLocal对象获取Entry对象,Entry不为空就返回value。

3、如果ThreadLocalMap或者Entry为空就创建一个Map并设置初始值然后将初始值返回。

set方法源码分析
public void set(T value) {
    Thread t = Thread.currentThread();
    // 通过当前线程获取ThreadLocalMap
    ThreadLocalMap map = getMap(t);
    if (map != null)
        // 调用map的set方法设置map的值
        map.set(this, value);
    else
        // 创建一个新的map并设置值
        createMap(t, value);
}

1、获取当前线程中ThreadLocalMap

2、如果map不为空就调用map的set方法设置zhi,否则就创建一个新的map然后设置值

remove方法源码分析
public void remove() {
    // 通过当前线程获取ThreadLocalMap
    ThreadLocalMap m = getMap(Thread.currentThread());
    if (m != null)
        // 删除当前ThreadLocal的值
        m.remove(this);
}

1、获取当前线程中ThreadLocalMap

2、如果map不为空就调用map的remove方法删除当前ThreadLocal的值

ThreadLocalMap

ThreadLocalMap的set流程

1.通过传入的key的hashcode计算索引位置。

2.从索引位置开始遍历,直到节点为空时才停下。

3.在遍历期间,如果节点的k与传入的key相同,则新值替换旧值,结束。

4.在遍历期间,如果节点k=null,则该节点需要被清理,调用replaceStaleEntry清理该节点,结束。

5.退出遍历,代表找到了空位置,则新建节点,并放在该位置。

6.调用cleanSomeSlots清理key为null的节点,并判断是否需要扩容,若需要则调用rehash扩容。

ThreadLocalMap的rehash流程

1.初始容量16,扩容阈值2/3。

2.调用方法,清理key为null的节点。

3.如果清理后剩余节点>=3/4阈值,则进行扩容。

4.初始化新table,长度为老table的两倍。

5.遍历老节点,如果key为null,则value也赋值为null,方便回收。

6.计算其在新数组中的位置,如果位置上已经存在元素了,则继续找下一节点,直到节点为空,就把老节点放进去。

7.设置新表扩容的阈值、更新size、table指向新表。

ThreadLocalMap的remove方法

1.通过key的hash计算其在索引中的位置。

2.从当前位置开始,向后遍历,直到找到key与传入key相同的entry,然后将其key置为null,然后调用expungeStaleEntry清理key为null的节点,或者节点为null的为止。

ThreadLocalMap的getEntry

1.根据key的hash,计算其索引位置。

2.获取索引位置的Entry,看其key是否为传入的key,若是,则返回此Entry。

3.若不是,则说明插入时产生了hash冲突。从当前节点开始向后遍历,若key=传入key,则返回Entry。若key=null,则进行脏entry清理。直到节点为空遍历结束,返回null。

ThreadLocalMap索引计算方法

index = getAndAdd(2 ^ 32 * 黄金分割),目的让index更加均匀,减少冲突。

ThreadLocal导致内存泄露

ThreadLocalMap使用ThreadLocal的弱引用作为Entry的key,如果一个ThreadLocal没有外部强引用来引用它,下一次系统GC时,这个ThreadLocal必然会被回收,这样一来,ThreadLocalMap中就会出现key为null的Entry,而Entry的value又是强引用,就会导致key为null的引用获取不到也没办法清除,就会导致内存泄漏。

那么为什么Entry的key要使用弱引用而不是强引用,以及如何解决内存泄漏问题

这是因为若Entry持有ThreadLocal的强引用,那么将代码里的threadlocal=null时,它也不会被回收,因为Entry的key还强持有threadlocal。若使用弱引用的话,当强引用没有时,GC就会回收弱引用持有的对象。

调用get()、set()、remove()方法时,都会清理key为null的entry。

ThreadLocal使用场景

  • 用ThreadLocal管理数据库连接,一个线程一个连接,实现事务。

  • 用ThreadLocal封装SimpleDataFormat对象,解决其线程不安全问题。

  • cookie、session。

  • 实例需要在多个方法中共享,但不希望被多线程共享的context。

Java并发集合

ConcurrentHashMap

ConcurrentHashMap的initTable原理
private final Node<K,V>[] initTable() {
    Node<K,V>[] tab; int sc;
    // 数组为空才执行初始化流程
    while ((tab = table) == null || tab.length == 0) {
        // 判断sizeCtl的值是否小于0,小于0表示其他线程正在执行初始化
        if ((sc = sizeCtl) < 0)
            // 如果其他线程正在执行初始化那么让出cpu
            Thread.yield(); // lost initialization race; just spin
        // 否则使用CAS修改sizeCtl的值为-1,成功表示可以执行初始化操作
        else if (U.compareAndSwapInt(this, SIZECTL, sc, -1)) {
            try {
                // 再次判断数组是否为空
                if ((tab = table) == null || tab.length == 0) {
                    int n = (sc > 0) ? sc : DEFAULT_CAPACITY;
                    @SuppressWarnings("unchecked")
                    // 初始化数组,长度为DEFAULT_CAPACITY,也就是16
                    Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n];
                    table = tab = nt;
                    // 计算阈值
                    sc = n - (n >>> 2);
                }
            } finally 
                // 将阈值赋值给sizeCtl
                sizeCtl = sc;
            }
            break;
        }
    }
    return tab;
}

1、判断sizectl < 0,若是则表示其它线程正在初始化table。该线程则调用yield,让出cpu。

2、利用CAS将sizeCtl改写为-1,写成功则代表由该线程执行初始化(值为负数时:如果为-1表示正在初始化,如果为-N则表示当前正有N-1个线程进行扩容操作)。

3、初始化数组,并计算阈值 = 数组长度 * 负载因子。

4、在finally里面将sizeCtl = 阈值。

ConcurrentHashMap的put原理

1、根据key计算出hash。

2、判断table是否需要进行初始化,若需要则初始化。

3、通过hash定位出的Node,如果为空表示当前位置可以写入数据,利用CAS尝试写入,失败则自旋保证成功。

4、如果当前位置的hashcodeMOVED-1,说明其它线程再扩容,则帮助一起进行扩容。

5、如果都不满足说明有hash冲突,则利用synchronized锁,锁住当前头节点后,判断是链表还是红黑树,遍历插入。

6、当在链表长度>8的时候,数组长度>=64将链表转换为红黑树。

7、对当前键值对数量进行检查,如果超过了阈值(实际大小*负载因子)就需要扩容。

ConcurrentHashMap的get原理

1、根据key计算出hash,通过hash定位节点。

2、如果首节点与传入key相同,就直接返回。

3、如果是红黑树结构,就从红黑树里面查询。

4、如果是链表结构,循环遍历判断。

5、不需要加锁,因为node的value与next指针是由volatile修饰的,具有可见性。

ConcurrentHashMap的transfer扩容原理

1、构建nextTable,容量是原来的两倍。

2、计算出步长,最小值是16,然后给当前扩容线程分配一个步长的节点数,让该线程去对这16个节点进行扩容操作,以此循环往复。如扩容结束前又来线程,也会给其分配一个步长的节点数让该线程去扩容。

3、如果索引位置上为null,则直接使用CAS将索引位置赋值为ForwardingNode。

4、如果索引位置是Node节点,且是链表的头节点,就构造两个反序链表,把他们分别放在nextTable的i和i + n的位置上(与HashMap的逻辑一致)。
5、如果索引位置是TreeBin节点,也做一个反序处理,把处理的结果分别放在nextTable的i和i+n的位置上(与HashMap的逻辑一致)。
6、遍历过所有的节点以后就完成了复制工作,这时让nextTable作为新的table,并且更新sizeCtl为数组新长度的0.75倍(sizeCtl = (n << 1) - (n >>> 1)) ,完成扩容。

CopyOnWriteArrayList

CopyOnWriteArrayList的add原理

1、获取之前的数组和数组长度。

2、通过Arrays.copyOf方法将原来的数组拷贝一份,长度是旧的数组长度+1。

3、将元素添加到新数组的最后一个索引位置

4、使用新数组替换旧数组

CopyOnWriteArrayList的get原理

1、直接返回数组中指定索引位置

CopyOnWriteArrayList的set原理

1、获取旧数组中指定索引的元素

2、判断旧的元素和传入的新元素是否相等

3、如果不相等就拷贝一个新数组,将新数组的指定索引位置设置为新的元素,然后用新数组替换旧数组

阻塞队列(BlcokingQueue)

常用阻塞队列

  • ArrayBlockingQueue :基于数组实现,有界,先进先出,默认非公平可设置为公平。
  • LinkedBlockingQueue:基于链表实现,无界,最大为int的最大值。先进先出,默认非公平可设置为公平。
  • LinkedBlockingDeque:基于链表实现,无界,双向队列。
  • PriorityBlockingQueue:基于二叉堆实现,无界,它判断元素的大小即可根据元素(实现Comparable接口)的本身大小来自然排序,也可使用Comparator进行定制排序。
  • DelayQueue:底层基于PriorityBlockingQueue实现的无界阻塞队列,不过集合元素都实现Delay接口(该接口里只有一个long getDelay()方法),DelayQueue根据集合元素的getDalay()方法的返回值进行排序。
  • SynchronousQueue:不存储元素的阻塞队列,基于TransferQueue与TransferStack实现,每个take必须等待一个put,没等到之前都会阻塞。
  • **LinkedTransferQueue:**由链表组成的无界阻塞队列。

队列常用方法

方法抛出异常返回特殊值一直阻塞超时
插入add(e)offer(e)put(e)offer(e, time, unit)
移除remove()poll()take()poll(time, unit)
检查element()peek()不可用不可用

**抛出异常:**当阻塞队列满时,再往队列里面add插入元素会抛出IllegalStateException:Queue full异常。当阻塞队列为空空时,再往队列里remove移除元素会抛出NoSuchElementException异常。

**返回特殊值:**插入方法:成功返回true,失败返回false。移除方法:成功返回队列的元素,队列里没有就返回null。

**一直阻塞:**当阻塞队列满时,尝试往队列里面put元素,队列会一直阻塞线程直到put进入数据或者响应中断退出。当阻塞队列空时,尝试从队列里面take元素,队列会一直阻塞线程直到队列中有元素为止。

**超时:**队列会阻塞线程一段时间,超过指定时间后线程就会退出。

ArrayBlockingQueue源码分析

add方法
public boolean add(E e) {
    // 调用的父类的add方法
    return super.add(e);
}

// 父类AbstractQueue的add方法
public boolean add(E e) {
    // 调用offer方法实现
    if (offer(e))
        return true;
    else
        // 如果返回false抛出异常
        throw new IllegalStateException("Queue full");
}

offer方法
public boolean offer(E e) {
    // 判断元素e是否为空
    checkNotNull(e);
    final ReentrantLock lock = this.lock;
    lock.lock();
    try {
        // 判断队列是否已满
        if (count == items.length)
            // 已满就返回false
            return false;
        else {
            // 将元素加入队列然后返回true
            enqueue(e);
            return true;
        }
    } finally {
        lock.unlock();
    }
}


private void enqueue(E x) {
    // assert lock.getHoldCount() == 1;
    // assert items[putIndex] == null;
    final Object[] items = this.items;
    // putIndex存储的下一个新增元素的索引
    items[putIndex] = x;
    // 如果本次插入元素后队列满了就将putIndex的值设置为0
    if (++putIndex == items.length)
        putIndex = 0;
    count++;
    // 唤醒出队线程头节点
    notEmpty.signal();
}
put方法
public void put(E e) throws InterruptedException {
    // 检查e是否为空
    checkNotNull(e);
    final ReentrantLock lock = this.lock;
    lock.lockInterruptibly();
    try {
        // 判断队列是否已满
        while (count == items.length)
            // 如果队列已满,调用await方法加入等待队列
            notFull.await();
        // 否则将元素加入队列
        enqueue(e);
    } finally {
        lock.unlock();
    }
}
poll方法
public E poll() {
    final ReentrantLock lock = this.lock;
    lock.lock();
    try {
        // 如果元素为空则返回null,否则调用dequeue方法
        return (count == 0) ? null : dequeue();
    } finally {
        lock.unlock();
    }
}

// 移除并返回队首的元素
private E dequeue() {
    // assert lock.getHoldCount() == 1;
    // assert items[takeIndex] != null;
    final Object[] items = this.items;
    @SuppressWarnings("unchecked")
    // 获取队首元素
    E x = (E) items[takeIndex];
    // 将队列该位置设置为null,表示已取出
    items[takeIndex] = null;
    // 判断本次取出之后是否已经将队列的元素取完
    if (++takeIndex == items.length)
        // 取完了就将takeIndex设置为0
        takeIndex = 0;
    // 计数器自减
    count--;
    if (itrs != null)
        itrs.elementDequeued();
    // 唤醒入队线程的头节点
    notFull.signal();
    return x;
}
take方法
public E take() throws InterruptedException {
    final ReentrantLock lock = this.lock;
    lock.lockInterruptibly();
    try {
        while (count == 0)
            // 如果队列为空则调用await加入等待队列
            notEmpty.await();
        // 否则就取出队首的元素并返回
        return dequeue();
    } finally {
        lock.unlock();
    }
}

LinkedBlockingQueue源码分析

add方法
// AbstractQueue的add方法
public boolean add(E e) {
    if (offer(e))
        return true;
    else
        throw new IllegalStateException("Queue full");
}
offer方法
public boolean offer(E e) {
    if (e == null) throw new NullPointerException();
    final AtomicInteger count = this.count;
    if (count.get() == capacity)
        return false;
    int c = -1;
    // 将当前元素构建成一个节点
    Node<E> node = new Node<E>(e);
    final ReentrantLock putLock = this.putLock;
    putLock.lock();
    try {
        // 判断元素个数是否小于队列容量
        if (count.get() < capacity) {
            // 如果小于则调用enqueue将节点加入队尾
            enqueue(node);
            c = count.getAndIncrement();
            if (c + 1 < capacity)
                // 唤醒插入元素等待队列中的头节点
                notFull.signal();
        }
    } finally {
        putLock.unlock();
    }
    // 判断添加元素是否成功
    if (c == 0)
        // 唤醒出队等待队列的头节点
        signalNotEmpty();
    return c >= 0;
}

// 将节点加入队尾
private void enqueue(Node<E> node) {
    // assert putLock.isHeldByCurrentThread();
    // assert last.next == null;
    last = last.next = node;
}
put方法
public void put(E e) throws InterruptedException {
    if (e == null) throw new NullPointerException();
    int c = -1;
    // 将元素封装成节点
    Node<E> node = new Node<E>(e);
    final ReentrantLock putLock = this.putLock;
    final AtomicInteger count = this.count;
    putLock.lockInterruptibly();
    try {
        // 判断队列是否已满
        while (count.get() == capacity) {
            // 调用await方法加入等待队列
            notFull.await();
        }
        // 否则调用enqueue入队
        enqueue(node);
        c = count.getAndIncrement();
        if (c + 1 < capacity)
            // 唤醒插入等待队列中的头节点
            notFull.signal();
    } finally {
        putLock.unlock();
    }
    if (c == 0)
        // 唤醒出队等待队列的头节点
        signalNotEmpty();
}
poll方法
public E poll() {
    final AtomicInteger count = this.count;
    if (count.get() == 0)
        return null;
    E x = null;
    int c = -1;
    final ReentrantLock takeLock = this.takeLock;
    takeLock.lock();
    try {
        // 判断队列是否有元素
        if (count.get() > 0) {
            // 调用dequeue方法移除队首的元素
            x = dequeue();
            c = count.getAndDecrement();
            if (c > 1)
                // 唤醒出队等待队列的头节点
                notEmpty.signal();
        }
    } finally {
        takeLock.unlock();
    }
    if (c == capacity)
        // 唤醒入队等待队列的头节点
        signalNotFull();
    return x;
}

// 移除队首的元素
private E dequeue() {
    Node<E> h = head;
    Node<E> first = h.next;
    h.next = h; // help GC
    head = first;
    E x = first.item;
    first.item = null;
    return x;
}
take方法
public E take() throws InterruptedException {
    E x;
    int c = -1;
    final AtomicInteger count = this.count;
    final ReentrantLock takeLock = this.takeLock;
    takeLock.lockInterruptibly();
    try {
        // 判断队列是否有元素
        while (count.get() == 0) {
            // 没有元素就调用await方法加入等待队列
            notEmpty.await();
        }
        // 调用dequeue方法移除队首元素
        x = dequeue();
        c = count.getAndDecrement();
        if (c > 1)
            // 唤醒出队等待队列的头节点
            notEmpty.signal();
    } finally {
        takeLock.unlock();
    }
    if (c == capacity)
        // 唤醒入队节点
        signalNotFull();
    return x;
}

线程池

线程池是一种池化技术,指提前创建好一些线程,然后将这些线程缓存起来,后续要使用线程的时候就去线程池里面拿,用完之后再还给线程池。

为什么要使用线程池

  • **降低资源消耗:**通过重复利用已创建的线程,降低线程创建和销毁造成的消耗。
  • **提高响应速度:**当任务到达时,任务可以不需要等到线程创建就能立即执行。
  • **增加线程的可管理型:**线程是稀缺资源,使用线程池可以进行统一分配、调优和监控。

ThreadPoolExecutor(推荐使用)

主要参数
  • **corePoolSize:**核心线程数,线程池中要保留的线程数,即使它们是空闲的也不会销毁。
  • **maximumPoolSize:**线程池能创建的最大线程数,最大线程数 = 核心线程数 + 非核心线程数。
  • **keepAliveTime:**空闲时间,当线程池中线程数大于核心线程数并且空闲的线程的存活时间如果超过指定的的空闲时间,那么这个线程就会被销毁,直到线程池中的线程数小于等于核心线程数。
  • **unit:**空闲时间的时间单位。
  • **workQueue:**用于传输和保存等待执行任务的阻塞队列。
  • **threadFactory:**创建新线程的工厂。
    • **DefaultThreadFactory:**默认线程工厂,创建一个新的、非守护的线程,并且不包含特殊的配置信息。
    • **PrivilegedThreadFactory:**通过这种方式创建出来的线程,将与创建privilegedThreadFactory的线程拥有相同的访问权限、 AccessControlContext、ContextClassLoader。如果不使用privilegedThreadFactory, 线程池创建的线程将从在需要新线程时调用execute或submit的客户程序中继承访问权限。
    • **自定义线程工厂:**可以自己实现ThreadFactory接口来定制自己的线程工厂方法。
  • **handler:**拒绝策略,线程池的线程数达到最大线程数并且workQueue已满,则会执行拒绝策略。
    • **ThreadPoolExecutor.AbortPolicy:**丢弃任务并抛出RejectedExecutionException异常(默认)。
    • **ThreadPoolExecutor.DiscardPolicy:**也是丢弃任务,但是不抛出异常。
    • **ThreadPoolExecutor.DiscardOldestPolicy:**丢弃队列最前面的任务,然后重新尝试执行任务(不适合与PriorityBlockingQueue一起使用)。
    • **ThreadPoolExecutor.CallerRunsPolicy:**由调用线程处理该任务 。

ThreadPoolExecutor的全参构造方法如下:

public ThreadPoolExecutor(int corePoolSize,
                          int maximumPoolSize,
                          long keepAliveTime,
                          TimeUnit unit,
                          BlockingQueue<Runnable> workQueue,
                          ThreadFactory threadFactory,
                          RejectedExecutionHandler handler)
ThreadPoolExecutor线程池执行流程

线程池的执行流程如下图所示:

在这里插入图片描述

1、提交任务时判断执行任务的线程是否达到核心线程数,如果没达到就使用核心线程执行。

2、如果达到了核心线程数,就判断阻塞队列是否已满,如果阻塞队列未满就将任务添加到阻塞队列。

3、如果阻塞队列已满,就判断当前执行任务的线程是否达到指定的最大线程数,如果没达到就创建新的线程执行。

4、如果执行任务的线程已经达到最大线程数,那么就会执行拒绝策略。

5、当非核心线程空闲时,如果存活时间达到了最大空闲时间还没有任务执行,那么就会被销毁。

线程池状态

RUNNING:接受新任务并处理排队的任务。

SHUTDOWN:不接受新任务,但处理排队的任务。

STOP:不接受新任务,不处理排队的任务,并中断正在进行的任务。

TIDYING:所有任务都已终止,workerCount 为零,线程转换到 TIDYING 状态将运行 terminated() 钩子方法。

TERMINATED:terminated() 已完成。

线程池状态流转

在这里插入图片描述

当创建线程池后,初始时,线程池处于RUNNING状态。

如果调用了shutdown()方法,则线程池处于SHUTDOWN状态,此时线程池不能够接受新的任务,它会等待所有任务执行完毕。

如果调用了shutdownNow()方法,则线程池处于STOP状态,此时线程池不能接受新的任务,并且会去尝试终止正在执行的任务。

使用队列有什么注意的问题

若使用有界队列,需注意队列与最大线程数满了后的拒绝策略。

若使用无界队列,需注意若生产者提交速度不能大于线程池处理速度,可能会造成内存溢出。

核心线程只有在任务到达时才能启动吗

可以使用prestartAllCoreThreads或prestartlCoreThreads来启动所有或者一个核心线程。

核心线程怎么实现一直存活

获取任务时,核心线程调用阻塞队列的take,若阻塞队列为空,则会阻塞。

非核心线程如何实现keepAliveTime的存活

获取任务时,调用阻塞队列的poll,若阻塞队列为空,则会等待一定的时间。

如何配置线程池

**CPU密集型任务:**尽量使用较小的线程池,一般为CPU核心数+1。 因为CPU密集型任务使得CPU使用率很高,若开过多的线程数,会造成CPU过度切换。

**IO密集型任务:**可以使用稍大的线程池,一般为2*CPU核心数。 IO密集型任务CPU使用率并不高,因此可以让CPU在等待IO的时候有其他线程去处理别的任务,充分利用CPU时间。

**混合型任务:**将任务分成IO密集型和CPU密集型任务,然后分别用不同的线程池去处理。 只要分完之后两个任务的执行时间相差不大,那么就会比串行执行来的高效。

**经验公式:*最佳线程数目 = (线程等待时间/线程CPU时间 + 1) CPU数目。

**动态配置线程池参数:**参考美团技术方案【https://mp.weixin.qq.com/s?__biz=MjM5NjQ5MTI5OA==&mid=2651751537&idx=1&sn=c50a434302cc06797828782970da190e&chksm=bd125d3c8a65d42aaf58999c89b6a4749f092441335f3c96067d2d361b9af69ad4ff1b73504c&scene=21#wechat_redirect】

Executors

Executors创建线程池的主要方法
  • **newCachedThreadPool:**创建一个可扩大到MAX的线程池,适用于负载较轻的场景,执行短期异步任务。(可以使得任务快速得到执行,因为任务时间执行短,可以很快结束,也不会造成cpu过度切换)
    • **corePoolSize:**0
    • **maximumPoolSize:**Integer.MAX_VALUE
    • **keepAliveTime:**60s
    • **workQueue:**SynchronousQueue
  • **newFixedThreadPool:**创建一个固定大小的线程池,因为采用有界的阻塞队列,所以实际线程数量永远不会变化,适用于负载较重的场景,对当前线程数量进行限制。(保证线程数可控,不会造成线程过多,导致系统负载更为严重)
    • **corePoolSize:**nThreads,指定大小。
    • **maximumPoolSize:**nThreads,指定大小。
    • **keepAliveTime:**0
    • **workQueue:**LinkedBlockingQueue
  • **newSingleThreadExecutor:**创建一个单线程的线程池,适用于需要保证顺序执行各个任务。
    • **corePoolSize:**1
    • **maximumPoolSize:**1
    • **keepAliveTime:**0
    • **workQueue:**LinkedBlockingQueue
  • **newScheduledThreadPool:**适用于执行延时或者周期性任务
    • **corePoolSize:**corePoolSize
    • **maximumPoolSize:**Integer.MAX_VALUE
    • **keepAliveTime:**0
    • **workQueue:**DelayQueue
Executors创建线程池弊端

**newFixedThreadPool和newSingleThreadExecutor:**堆积的请求处理队列可能会耗费非常大的内存,甚至 OOM。

**newCachedThreadPool和newScheduledThreadPool:**主要问题是线程数最大数是 Integer.MAX_VALUE,可能会创建数量非常多的线程,甚至 OOM。

ForkJoinPool

ForkJoinPool使用分治算法,用相对少的线程处理大量的任务,将一个大任务一拆为二,以此类推,每个子任务再拆分一半,直到达到最细颗粒度为止,即设置的阈值停止拆分,然后从最底层的任务开始计算,往上一层一层合并结果。

在这里插入图片描述

ForkJoinTask

ForkJoinTask是 Java 中用于支持分治任务的抽象类,通常与 ForkJoinPool 结合使用。它的作用是将一个大的任务拆分成若干个较小的子任务,并使用多线程并行执行这些子任务,最终将它们的结果合并得到整体的结果。

ForkJoinTask有两个主要的子类:

  • RecursiveAction: 用于处理不需要返回结果的任务,通常用于执行一些对数据进行修改的操作。
  • **RecursiveTask:**用于处理需要返回结果的任务,可以看作是带有返回值的 RecursiveAction。
ForkJoinPool代码案例

下面我们通过ForkJoinPool来计算1到一千万的和:

public class ForkJoinPoolTest {
    public static class SumTask extends RecursiveTask<Long> {
        private long[] numbers;
        private int from;
        private int to;

        public SumTask(long[] numbers, int from, int to) {
            this.numbers = numbers;
            this.from = from;
            this.to = to;
        }

        @Override
        protected Long compute() {
            if (to - from < 10) { // 设置拆分的最细粒度,即阈值,如果满足条件就不再拆分,执行计算任务
                long total = 0;
                for (int i = from; i <= to; i++) {
                    total += numbers[i];
                }
                return total;
            } else { // 否则继续拆分,递归调用
                int middle = (from + to) / 2;
                SumTask taskLeft = new SumTask(numbers, from, middle);
                SumTask taskRight = new SumTask(numbers, middle + 1, to);
                taskLeft.fork();
                taskRight.fork();
                return taskLeft.join() + taskRight.join();
            }
        }
    }

    public static void main(String[] args) {
        // 可以在构造函数内指定线程数
        ForkJoinPool forkJoinPool = new ForkJoinPool();
        long[] numbers = LongStream.rangeClosed(1, 10000000).toArray();
        // 这里可以调用submit方法返回的future,通过future.get获取结果
        Long result = forkJoinPool.invoke(new SumTask(numbers, 0, numbers.length - 1));
        forkJoinPool.shutdown();
        System.out.println("最终结果:" + result);
        System.out.println("活跃线程数:" + forkJoinPool.getActiveThreadCount());
        System.out.println("窃取任务数:" + forkJoinPool.getStealCount());
    }
}

执行结果:

最终结果:50000005000000
活跃线程数:1
窃取任务数:95

会耗费非常大的内存,甚至 OOM。

**newCachedThreadPool和newScheduledThreadPool:**主要问题是线程数最大数是 Integer.MAX_VALUE,可能会创建数量非常多的线程,甚至 OOM。

ForkJoinPool

ForkJoinPool使用分治算法,用相对少的线程处理大量的任务,将一个大任务一拆为二,以此类推,每个子任务再拆分一半,直到达到最细颗粒度为止,即设置的阈值停止拆分,然后从最底层的任务开始计算,往上一层一层合并结果。

[外链图片转存中…(img-hwL2Q5bL-1710170489902)]

ForkJoinTask

ForkJoinTask是 Java 中用于支持分治任务的抽象类,通常与 ForkJoinPool 结合使用。它的作用是将一个大的任务拆分成若干个较小的子任务,并使用多线程并行执行这些子任务,最终将它们的结果合并得到整体的结果。

ForkJoinTask有两个主要的子类:

  • RecursiveAction: 用于处理不需要返回结果的任务,通常用于执行一些对数据进行修改的操作。
  • **RecursiveTask:**用于处理需要返回结果的任务,可以看作是带有返回值的 RecursiveAction。
ForkJoinPool代码案例

下面我们通过ForkJoinPool来计算1到一千万的和:

public class ForkJoinPoolTest {
    public static class SumTask extends RecursiveTask<Long> {
        private long[] numbers;
        private int from;
        private int to;

        public SumTask(long[] numbers, int from, int to) {
            this.numbers = numbers;
            this.from = from;
            this.to = to;
        }

        @Override
        protected Long compute() {
            if (to - from < 10) { // 设置拆分的最细粒度,即阈值,如果满足条件就不再拆分,执行计算任务
                long total = 0;
                for (int i = from; i <= to; i++) {
                    total += numbers[i];
                }
                return total;
            } else { // 否则继续拆分,递归调用
                int middle = (from + to) / 2;
                SumTask taskLeft = new SumTask(numbers, from, middle);
                SumTask taskRight = new SumTask(numbers, middle + 1, to);
                taskLeft.fork();
                taskRight.fork();
                return taskLeft.join() + taskRight.join();
            }
        }
    }

    public static void main(String[] args) {
        // 可以在构造函数内指定线程数
        ForkJoinPool forkJoinPool = new ForkJoinPool();
        long[] numbers = LongStream.rangeClosed(1, 10000000).toArray();
        // 这里可以调用submit方法返回的future,通过future.get获取结果
        Long result = forkJoinPool.invoke(new SumTask(numbers, 0, numbers.length - 1));
        forkJoinPool.shutdown();
        System.out.println("最终结果:" + result);
        System.out.println("活跃线程数:" + forkJoinPool.getActiveThreadCount());
        System.out.println("窃取任务数:" + forkJoinPool.getStealCount());
    }
}

执行结果:

最终结果:50000005000000
活跃线程数:1
窃取任务数:95
  • 29
    点赞
  • 28
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
### 回答1: 高并发时序数据存储的一种专利,该专利提供了一种利用可伸缩的数据模型来存储时序数据的方法,在收集、存储、管理和检索时序数据的过程中提供了更高的性能和可伸缩性。专利提出了一种利用可伸缩的数据模型来存储时序数据的方法,它可以支持高并发量的时序数据存储,从而有效提高时序数据收集、存储、管理和检索的性能。 ### 回答2: 标题:基于高并发时序数据存储的方法与系统 摘要:本专利涉及一种基于高并发时序数据存储的方法与系统,旨在解决传统数据存储方法在处理大规模时序数据时的性能问题。该方法与系统采用分布式架构,具备高并发、高可靠和高可用性的特点,适用于大数据分析、物联网、金融交易等领域。 1. 技术背景: 随着互联网和物联网的快速发展,海量时序数据的产生和存储成为一个重要挑战。传统的关系型数据库和文件系统已经无法满足高并发和高吞吐量的需求。因此,本专利提供一种针对高并发时序数据存储的解决方案。 2. 发明内容: 本专利提供了一种基于高并发时序数据存储的方法和系统。该方法主要包括以下步骤: a) 数据分区与负载均衡:将大规模的时序数据根据时间戳进行分区,并采用负载均衡策略将数据均匀地分布到多个存储节点上,以实现高并发和高吞吐量的数据处理; b) 数据存储与索引:利用分布式存储技术,在每个存储节点上存储时序数据,并建立索引,以支持快速的数据检索和查询; c) 冷热数据分离:根据数据的使用频率,将热数据存储在高速存储介质上,而将冷数据存储在低成本的介质上,以提高存储效率和降低成本; d) 容灾与备份:采用多副本备份和容灾技术,确保时序数据的安全性和可靠性; e) 数据压缩与清理:对历史数据进行压缩和清理,以减少存储空间的占用率。 3. 优势和创新点: 本专利的方法与系统具有以下优势和创新点: a) 高并发能力:采用分布式架构和负载均衡策略,实现高并发和高吞吐量的数据处理; b) 高可靠性:采用多副本备份和容灾技术,确保时序数据的安全性和可靠性; c) 高扩展性:支持动态增加存储节点,以应对数据规模的增长; d) 低成本:通过冷热数据分离和数据压缩技术,降低存储成本; e) 快速查询:通过建立索引和优化查询算法,实现快速的数据检索和查询。 总结:本专利提供了一种高并发时序数据存储的方法与系统,具备高并发、高可靠和高可用性的特点,适用于大规模时序数据的处理和分析。该方法与系统在大数据分析、物联网、金融交易等领域具有广阔的应用前景。 ### 回答3: 题目:高并发时序数据存储的专利申请 摘要: 本发明涉及一种用于高并发时序数据存储的系统和方法。当前,大数据和物联网应用的快速发展使得时序数据存储需求在不断增加。本发明提出了一种高并发时序数据存储方案,可实现在高并发情况下高效地存储和检索时序数据,使得大规模数据的采集、处理和分析更加便捷。 背景: 现有技术中,传统的时序数据存储方法在遇到高并发请求时存在存储吞吐量低、延迟高等问题。本发明针对这些问题提出了一种新的高并发时序数据存储方案,期望优化存储系统的性能和效率。 发明内容: 本发明提出了一种高并发时序数据存储系统,包括数据采集模块、存储处理模块和数据检索模块。其中,数据采集模块负责从不同设备或传感器中接收并采集时序数据,并对数据进行预处理。存储处理模块负责将经过预处理的数据存储到数据库中,并进行数据的归档、压缩和索引。数据检索模块负责从数据库中提取时序数据,并支持多维度的数据查询操作。 本发明的关键在于提出了一种优化存储系统性能的数据归档方案。在高并发情况下,将时序数据按照特定的规则进行归档,能够降低数据的访问延迟和提高存储吞吐量。另外,通过对时序数据进行压缩和索引,能够进一步减少存储空间占用和提高检索效率。 创新点: 1. 本发明提出了一种高并发时序数据存储方案,能够解决传统存储方法在高并发情况下的性能瓶颈问题。 2. 引入了一种优化存储系统性能的数据归档方案,能够降低访问延迟和提高存储吞吐量。 3. 通过时序数据的压缩和索引,能够减少存储空间占用和提高检索效率。 应用前景: 本发明的高并发时序数据存储方案在大数据和物联网应用中具有广泛的应用前景。例如,在工业生产监测、交通运输管理和环境监测等领域,大规模时序数据的采集、存储和分析需求日益增长,本发明能够提供高效、可靠的时序数据存储解决方案。 结论: 本发明提出了一种高并发时序数据存储的专利申请,通过优化存储系统的性能和效率,能够实现在高并发情况下高效地存储和检索时序数据。本发明的应用前景广阔,有望在大数据和物联网领域发挥重要作用。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值