近五万字关于Java并发(JUC)与虚拟机(JVM)面试题(尚硅谷第二季Java面试题学习笔记)

JUC面试题

JUC(java.util.concurrent)

  • 进程和线程
    • 进程:后台运行的程序(我们打开的一个软件,就是进程)
    • 线程:轻量级的进程,并且一个进程包含多个线程(同在一个软
      件内,同时运行窗口,就是线程)
  • 并发和并行
    • 并发:同时访问某个东西,就是并发
    • 并行:一起做某些事情,就是并行

JUC 下的三个包

  • java.util.concurrent
    • java.util.concurrent.atomic
    • java.util.concurrent.locks

Volatile 和 JMM 内存模型

Volatile简介

Volatile 在日常的单线程环境是应用不到的,Volatile 是 Java 虚拟机提供的轻量级的同步机制,轻量级可以理解为是低配版的syncronized。它是一个修饰符,可以用来修饰变量,被其修饰的变量会有具有volatile的特性,volatile其有三大特性:

  1. 保证可见性
  2. 不保证原子性
  3. 禁止指令重排

JMM内存模型

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

所有的变量都存储在主内存(Main Memory)中。每个线程还有自己的工作内存(Working Memory),线程的工作内存中保存了该线程使用到的变量的主内存的副本拷贝,线程对变量的所有操作(读取、赋值等)都必须在工作内存中进行,而不能直接读写主内存中的变量。不同的线程之间也无法直接访问对方工作内存中的变量,线程之间值的传递都需要通过主内存来完成

JMM有三大特性,volatile 只保证了两个,即可见性和有序性,不满足原子性。

  • 可见性
  • 原子性
  • 有序性

JMM关于同步的规定:

  • 线程解锁前,必须把共享变量的值刷新回主内存

  • 线程加锁前,必须读取主内存的最新值到自己的工作内存

  • 加锁解锁是同一把锁

由于JVM运行程序的实体是线程,而每个线程创建时JVM都会为其创建一个工作内存(有些地方称为栈空间),工作内存是每个线程的私有数据区域,而Java内存模型中规定所有变量都存储在主内存,主内存是共享内存区域,所有线程都可以访问,但线程对变量的操作(读取赋值等)必须在工作内存中进行,首先要将变量从主内存拷贝的自己的工作内存空间,然后对变量进行操作,操作完成后再将变量写回主内存,不能直接操作主内存中的变量,各个线程中的工作内存中存储着主内存中的变量副本拷贝,因此不同的线程间无法访问对方的工作内存,线程间的通信(传值)必须通过主内存来完成,其简要访问过程如下图:

image-20221010094156243

**数据传输速率:**硬盘 < 内存 < < cache < CPU

上面提到了两个概念:主内存和工作内存

  • 主内存:就是计算机的运行内存,也就是经常提到的 8G 内存,16G 内存
  • 工作内存:当我们实例化 new student,那么 age = 25 也是存储在主内存中。当同时有三个线程同时访问 student 中的 age 变量时,那么每个线程都会拷贝一份到各自的工作内存,从而实现了变量的拷贝

image-20221010100029743

JMM 内存模型的可见性指的是当主内存区域中的值被某个线程写入更改后,其它线程会马上知晓更改后的值,并重新得到更改后的值。即其可见性就是主线程的修改对于其他线程是可见的。

为什么会有可见性这一说呢?因为通过前面对JMM的介绍,我们知道各个线程对主内存中共享变量的操作都是各个线程各自拷贝变量到自己的工作内存进行操作后再写回到主内存中的,而各个线程之间是不可见的,那么它们操作变量的结果对其他线程也是不可见的,这就会导致出现一定的错误。比如存在一个线程AAA修改了共享变量X的值但还未写回主内存时,另外一个线程BBB又对主内存中同一个共享变量X进行操作,但此时A线程工作内存中共享变量x对线程B来说并不可见,这种工作内存与主内存同步延迟现象就造成了可见性问题。

缓存一致性

为什么这里主线程中某个值被更改后,其它线程能马上知晓呢?其实这里是用到了总线嗅探技术在说嗅探技术之前,首先谈谈缓存一致性的问题,就是当多个处理器运算任务都涉及到同一块主内存区域的时候,将可能导致各自的缓存数据不一。为了解决缓存一致性的问题,需要各个处理器访问缓存时都遵循一些协议,在读写时要根据协议进行操作,这类协议主要有 MSI、MESI 等等。

MESI

当 CPU 写数据时,如果发现操作的变量是共享变量,即在其它 CPU 中也存在该变量的副本,会发出信号通知其它 CPU 将该内存变量的缓存行设置为无效,因此当其它 CPU 读取这个变量的时,发现自己缓存该变量的缓存行是无效的,那么它就
会从内存中重新读取。

总线嗅探

那么是如何发现数据是否失效呢?
这里是用到了总线嗅探技术,就是每个处理器通过嗅探在总线上传播的数据来检查自己缓存值是否过期了,当处理器发现自己的缓存行对应的内存地址被修改,就会将当前处理器的缓存行设置为无效状态,当处理器对这个数据进行修改操作的时
候,会重新从内存中把数据读取到处理器缓存中。

总线风暴

总线嗅探技术有哪些缺点?
由于 Volatile 的 MESI 缓存一致性协议,需要不断的从主内存嗅探和 CAS 循环,无效的交互会导致总线带宽达到峰值。因此不要大量使用 volatile 关键字,至于什么时候使用 volatile、什么时候用锁以及 Syschonized 都是需要根据实际场景的。

volatile的一致性

volatile 修饰的关键字,是为了增加多个线程之间的可见性,只要有一个线程修改了内存中的值那么其它线程也能马上感知主线程中变量的修改。

为什么volatile能实现这样的操作呢?

因为volatile关键字修饰的变量如果在线程的工作空间中被修改的话会立即将线程缓存中修改的数据值写到主内存中,然后由于volatile 的 MESI 缓存一致性协议,其他线程会不断从主内存进行总线嗅探,通过嗅探总线监测自己工作空间中缓存的数据值是不是过期了,过期(主内存中的数据进行修改)就设置缓存行失效,然后线程就会从主内存中读取修改后的变量的值改同步到自己线程的工作空间中,而不是直接读取线程内部的操作空间的值,这样就保证了数据不会出现错误,实现了线程之间的可见性。总结就是volatile的可见性指的是多个线程之间变量的修改是相互可见的。

代码验证

/**
 * 假设是主物理内存
 */
class MyData {
    //volatile int number = 0;
    int number = 0;

    public void addTo60() {
        this.number = 60;
    }
}
/**
 * 验证volatile的可见性
 * 1. 假设int number = 0, number变量之前没有添加volatile关键字修饰
 */
public class VolatileDemo {
    public static void main(String args []) {
        // 资源类
        MyData myData = new MyData();
        // AAA线程 实现了Runnable接口的,lambda表达式
        new Thread(() -> {
            System.out.println(Thread.currentThread().getName() + "\t come in");
            // 线程睡眠3秒,假设在进行运算
            try {
                TimeUnit.SECONDS.sleep(3);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            // 修改number的值
            myData.addTo60();
            // 输出修改后的值
            System.out.println(Thread.currentThread().getName() + "\t update number value:" + myData.number);
        }, "AAA").start();
        // main线程就一直在这里等待循环,直到number的值不等于零
        while(myData.number == 0) {}

        // 按道理这个值是不可能打印出来的,因为主线程运行的时候,number的值为0,所以一直在循环
        // 如果能输出这句话,说明AAA线程在睡眠3秒后,更新的number的值,重新写入到主内存,并被main线程感知到了
        System.out.println(Thread.currentThread().getName() + "\t mission is over");
    }
}

由于没有volatile修饰MyData类的成员变量numbermain线程将会卡在while(myData.number == 0) {},不能正常结束。若想正确结束,用volatile关键字修饰MyData类的成员变量number即可。

volatile不保证原子性

原子性指的是不可分割,完整性,也即某个线程正在做某个具体业务时,中间不可以被加塞或者被分割。需要整体完整要么同时成功,要么同时失败。

volatile不保证原子性代码演示:
class MyData2 {
    /**
     * volatile 修饰的关键字,是为了增加 主线程和线程之间的可见性,只要有一个线程修改了内存中的值,其它线程也能马上感知
     */
    volatile int number = 0;

    public void addPlusPlus() {
        number ++;
    }
}

public class VolatileAtomicityDemo {
	public static void main(String[] args) {
        MyData2 myData = new MyData2();
        // 创建10个线程,线程里面进行1000次循环
        for (int i = 0; i < 20; i++) {
            new Thread(() -> {
                // 里面
                for (int j = 0; j < 1000; j++) {
                    myData.addPlusPlus();
                }
            }, String.valueOf(i)).start();
        }
        // 需要等待上面20个线程都计算完成后,在用main线程取得最终的结果值
        // 这里判断线程数是否大于2,为什么是2?因为默认是有两个线程的,一个main线程,一个gc线程
        while(Thread.activeCount() > 2) {
            // yield表示不执行
            Thread.yield();
        }
        // 查看最终的值
        // 假设volatile保证原子性,那么输出的值应该为:  20 * 1000 = 20000
        System.out.println(Thread.currentThread().getName() + "\t finally number value: " + myData.number);
	}//最后的结果总是小于20000。
}
volatile不保证原子性理论解释

number++在多线程下是非线程安全的。

我们可以将代码编译成字节码,可看出number++被编译成4条指令。

2: getfield #2 // Field n:I执行 getfield 从主内存拿到原始 n
5: iconst_1
6: iadd执行 iadd 进行加 1 操作
7: putfield #2 // Field n:I执行 putfileld 把累加后的值写回主内存

假设我们没有加 synchronized那么第一步就可能存在着,三个线程同时通过getfield命令,拿到主存中的n值,然后三个线程,各自在自己的工作内存中进行加1操作,但他们并发进行 iadd 命令的时候,因为只能一个进行写,所以其它操作会被挂起,假设1线程,先进行了写操作,在写完后,由于volatile的可见性,应该需要告诉其它两个线程,主内存的值已经被修改了,但是因为太快了,其它两个线程,陆续执行 iadd命令,进行写入操作,这就造成了其他线程没有接受到主内存n的改变,从而覆盖了原来的值,出现写丢失,这样也就让最终的结果少于20000。

image-20221010113943213

问题解决

可加synchronized解决,但它是重量级同步机制,性能上有所顾虑。

如何不加synchronized解决number++在多线程下是非线程安全的问题?使用AtomicInteger。

代码展示:

import java.util.concurrent.atomic.AtomicInteger;

class MyData2 {
    /**
     * volatile 修饰的关键字,是为了增加 主线程和线程之间的可见性,只要有一个线程修改了内存中的值,其它线程也能马上感知
     */
	volatile int number = 0;
	AtomicInteger number2 = new AtomicInteger();

    public void addPlusPlus() {
        number ++;
    }
    
    public void addPlusPlus2() {
    	number2.getAndIncrement();
    }
}

public class VolatileAtomicityDemo {

	public static void main(String[] args) {
        MyData2 myData = new MyData2();

        // 创建10个线程,线程里面进行1000次循环
        for (int i = 0; i < 20; i++) {
            new Thread(() -> {
                // 里面
                for (int j = 0; j < 1000; j++) {
                    myData.addPlusPlus();
                    myData.addPlusPlus2();
                }
            }, String.valueOf(i)).start();
        }

        // 需要等待上面20个线程都计算完成后,在用main线程取得最终的结果值
        // 这里判断线程数是否大于2,为什么是2?因为默认是有两个线程的,一个main线程,一个gc线程
        while(Thread.activeCount() > 2) {
            // yield表示不执行
            Thread.yield();
        }
        // 查看最终的值
        // 假设volatile保证原子性,那么输出的值应该为:  20 * 1000 = 20000
        //输出结果main	 finally number value: 18766
        System.out.println(Thread.currentThread().getName() + "\t finally number value: " + myData.number);
        //输出结果main	 finally number2 value: 20000
        System.out.println(Thread.currentThread().getName() + "\t finally number2 value: " + myData.number2);
	}
}

除此之外,我们还可以引入synchronized,但它虽然能够保证原子性,但是为了解决 number++,而引入重量级的同步机制,有种杀鸡焉用牛刀的感觉。

Volatile 禁止指令重排

计算机在执行程序时,为了提高性能,编译器和处理器的常常会对指令做重排,一般分以下3种:

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

单线程环境里面尽管重排也可以确保程序最终执行结果和代码顺序执行的结果一致,但多线程环境中线程交替执行,由于编译器优化重排的存在,两个线程中使用的变量能否保证一致性是无法确定的,结果无法预测。

重排案例1
public void mySort{
	int x = 11;//语句1
    int y = 12;//语句2
    × = × + 5;//语句3
    y = x * x;//语句4
}

可重排序列:1234,2134,1324

但是指令重排也是有限制的,即不会出现下面的顺序4 3 2 1。因为处理器在进行重排时候,必须考虑到指令之间的数据依赖性。因为步骤 4需要依赖于 y 的申明以及 x 的申明,故因为存在数据依赖,无法首先执行。

重排案例2

int a,b,x,y = 0

线程1线程2
x = a;y = b;
b = 1;a = 2;
x = 0;y = 0;

如果编译器对这段程序代码执行重排优化后,可能出现下列情况:

线程1线程2
b = 1;a = 2;
x = a;y = b;
x = 2;y = 1;

这也就说明在多线程环境下,由于编译器优化重排的存在,两个线程中使用的变量能否保证一致性是无法确定的。上面的例子就是由于给变量赋值的语句提前了而导致的错误结果,这样造成的结果,和最开始的就不一致了,这就是导致重排后,结果和最开始的不
一样,因此为了防止这种结果出现,volatile 就规定禁止指令重排,为了保证数据的一致性

重排案例3
public class ReSortSeqDemo{
	int a = 0;
	boolean flag = false;
	public void method01(){
		a = 1;//语句1
		flag = true;//语句2
	}

	public void method02(){
    	if(flag){
        	a = a + 5; //语句3
    	}
    	System.out.println("retValue: " + a);//可能是6或1或5或0
    }
}

我们在单线程环境下按照正常的顺序,分别调用 method01() 和 method02() 。那么,最终输出就是a=6
但是如果在多线程环境下,因为方法1和方法2他们之间不存在数据依赖的问题,因此原先的顺序可能是

a = 1;
flag = true;
a = a + 5;
System.out.println("reValue:" + a);

但是在经过编译器,指令,或者内存的重排后,可能会出现这样的情况

flag = true;
a = a + 5;
System.out.println("reValue:" + a);
a = 1;

也就是先执行 flag = true 后,另外一个线程马上调用方法 2,同时满足 flag 的判断,最终让 a + 5,结果为 5,这样同样出现了数据不一致的问题。
出现这个结果的原因是:多线程环境中线程交替执行,由于编译器优化重排的存在,两个线程中使用的变量能否保证一致性是无法确定的,结果无法预测。这时就需要通过 volatile 来修饰,来保证线程安全性。

禁止指令重排小总结

volatile实现禁止指令重排优化,从而避免多线程环境下程序出现乱序执行的现象

先了解一个概念,内存屏障(Memory Barrier)又称内存栅栏,是一个CPU指令,它的作用有两个:

  • 保证特定操作的执行顺序

  • 保证某些变量的内存可见性(利用该特性实现volatile的内存可见性)。

由于编译器和处理器都能执行指令重排优化。如果在指令间插入一条Memory Barrier则会告诉编译器和CPU,不管什么指令都不能和这条Memory Barrier指令重排序,也就是说通过插入内存屏障禁止在内存屏障前后的指令执行重排序优化。内存屏障另外一个作用是强制刷出各种CPU的缓存数据,因此任何CPU上的线程都能读取到这些数据的最新版本。

对volatile变量进行写操作时,会在写操作后加入一条store屏障指令,将工作内存中的共享变量值刷新回到主内存。

image-20221010135554980

对Volatile变量进行读操作时,会在读操作前加入一条load屏障指令,从主内存中读取共享变量。

image-20221010135608946

线性安全性获得保证

工作内存与主内存同步延迟现象导致的可见性问题

  • 可以使用synchronized或volatile关键字解决,它们都可以使一个线程修改后的变量立即对其他线程可见。

对于指令重排导致的可见性问题和有序性问题

  • 可以利用volatile关键字解决,因为volatile的另外一个作用就是禁止重排序优化。

单例模式在多线程环境下可能存在安全问题

懒汉单例模式

public class SingletonDemo {
    private static SingletonDemo instance = null;

    private SingletonDemo () {
        System.out.println(Thread.currentThread().getName() + "\t 我是构造方法SingletonDemo");
    }
    public static SingletonDemo getInstance() {
        if(instance == null) {
            instance = new SingletonDemo();
        }
        return instance;
    }
    public static void main(String[] args) {
        // 这里的 == 是比较内存地址
        System.out.println(SingletonDemo.getInstance() == SingletonDemo.getInstance());
        System.out.println(SingletonDemo.getInstance() == SingletonDemo.getInstance());
        System.out.println(SingletonDemo.getInstance() == SingletonDemo.getInstance());
        System.out.println(SingletonDemo.getInstance() == SingletonDemo.getInstance());
    }
}
//输出结果:
//main    我是构造方法singletonDemo
//true
//true
//true
//true

但是在多线程环境运行上述代码,还能保证单例吗?

public class SingletonDemo {
    private static SingletonDemo instance = null;

    private SingletonDemo () {
        System.out.println(Thread.currentThread().getName() + "\t 我是构造方法SingletonDemo");
    }
    public static SingletonDemo getInstance() {
        if(instance == null) {
            instance = new SingletonDemo();
        }
        return instance;
    }
    public static void main(String[] args) {
        for (int i = 0; i < 10; i++) {
            new Thread(() -> {
                SingletonDemo.getInstance();
            }, String.valueOf(i)).start();
        }
    }
}

输出结果:

4	 我是构造方法SingletonDemo
2	 我是构造方法SingletonDemo
5	 我是构造方法SingletonDemo
6	 我是构造方法SingletonDemo
0	 我是构造方法SingletonDemo
3	 我是构造方法SingletonDemo
1	 我是构造方法SingletonDemo

从以上的结果我们可以看出,我们通过 SingletonDemo.getInstance() 获取到的对象,并不是同一个,而是被几个线程都进行了创建,那么在多线程环境下,单例模式如何保证呢?

**解决方法之一:**用synchronized修饰方法getInstance(),但它属重量级同步机制,它只允许一个线程同时访问获取实例的方法,虽然保证了数据的一致性,但减低了并发性,因此采用的比较少。

public synchronized static SingletonDemo getInstance() {
    if(instance == null) {
        instance = new SingletonDemo();
    }
    return instance;
}

**解决方法之二:**DCL(Double Check Lock双端检锁机制)

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

从输出结果来看,确实能够保证单例模式的正确性,但是上面的方法还是存在问题的,DCL(双端检锁)机制不一定是线程安全的,原因是有指令重排的存在。比如某一个线程执行到第一次检测,读取到的instance不为null时,instance的引用对象可能没有完成初始化,但是已经被创建出来了即不为null。

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

memory = allocate(); //1.分配对象内存空间
instance(memory); //2.初始化对象
instance = memory; //3.设置instance指向刚分配的内存地址,此时instance != null

步骤2和步骤3不存在数据依赖关系,而且无论重排前还是重排后程序的执行结果在单线程中并没有改变,因此这种重排优化是允许的。

memory = allocate(); //1.分配对象内存空间
instance = memory;//3.设置instance指向刚分配的内存地址,此时instance! =null,但是对象还没有初始化完成!
instance(memory);//2.初始化对象

由于指令重排只会保证串行语义的执行的一致性(单线程),而并不会关心多线程间的语义一致性,所以当一条线程访问instance不为null时,就会直接进行返回。但由于instance实例未必已初始化完成,所以此时虽然有引用,但是引用指向的地址并没有任何东西。这时就造成了线程安全问题。

所以需要引入 volatile,来保证出现指令重排的问题,从而保证单例模式的线程安全性

private static volatile SingletonDemo instance = null;

CAS

概念

CAS 的全称是 Compare-And-Swap,它是 CPU 并发原语。它的功能是判断内存某个位置的值是否为预期值,如果是则更改为新的值and返回true,不是则不进行修改,且返回false。这个过程是原子的。
CAS并发原语体现在Java语言中就是sun.misc.Unsafe 类的各个方法。调用 UnSafe类中的 CAS 方法,JVM 会帮我们实现出 CAS 汇编指令,这是一种完全依赖于硬件的功能,通过它实现了原子操作,再次强调,由于 CAS 是一种系统原语,原语属于操作系统应用范畴,是由若干条指令组成,用于完成某个功能的一个过程,并且原语的执行必须是连续的,在执行过程中不允许被中断,也就是说 CAS 是一条
CPU 的原子指令,不会造成所谓的数据不一致的问题,也就是说 CAS 是线程安全的。

代码演示
public class CASDemo{
    public static void main(string[] args){
    	// 创建一个原子类
        AtomicInteger atomicInteger = new AtomicInteger(5);// mian do thing. . . . ..
        /**
		* 一个是期望值,一个是更新值,只有期望值和原子类本身的值相同时,才能够更改
		* 假设三秒前,我拿的是 5,expect也为5,那么就需要更新成 2019
		*/
        System.out.println(atomicInteger.compareAndSet(5, 2019)+"\t current data: "+atomicInteger.get());
        System.out.println(atomicInteger.compareAndset(5, 1024)+"\t current data: "+atomicInteger.get());
    }
}

输出结果为

true    2019
false   2019

这是因为我们执行第一个的时候,期望值和原本值是满足的,因此修改成功,但是第二次后,主内存的值已经修改成了 2019,不满足期望值,因此返回了 false,本次写入失败。CAS这个方法是用于验证的,验证线程改值前后的原子类的值是否发生了变化。类似于 SVN 或者 Git 的版本号,如果没有人更改过,就能够正常提交,否则需要先将代码 pull 下来,合并代码后,然后提交

image-20221011094502835

CAS 底层原理

atomiclnteger.getAndIncrement();源码

public class AtomicInteger extends Number implements java.io.Serializable {
    private static final long serialVersionUID = 6214790243416807050L;
    // setup to use Unsafe.compareAndSwapInt for updates
    private static final Unsafe unsafe = Unsafe.getUnsafe();
    private static final long valueOffset;
	private volatile int value;
    
    static {
        try {
            valueOffset = unsafe.objectFieldOffset
                (AtomicInteger.class.getDeclaredField("value"));
        } catch (Exception ex) { throw new Error(ex); }
    }

    /**
     * Creates a new AtomicInteger with the given initial value.
     *
     * @param initialValue the initial value
     */
    public AtomicInteger(int initialValue) {
        value = initialValue;
    }
    /**
     * Creates a new AtomicInteger with initial value {@code 0}.
     */
    public AtomicInteger() {
    }
    ...     
    /**
     * Atomically increments by one the current value.
     * @return the previous value
     */
    public final int getAndIncrement() {
        //this指的是当前对象,valueOffset是内存偏移量也就是内存地址
        return unsafe.getAndAddInt(this, valueOffset, 1);
    }
    ...
}

从源码我们可以看出,底层又调用了一个 unsafe 类的 getAndAddInt 方法,所以接下来我们来看一看UnSafe类以及getAndAddInt方法的参数都是什么。

image-20221011100519833
1.UnSafe类

Unsafe类是CAS的核心类,由于 Java 方法无法直接访问底层系统,需要通过本地(Native)方法来访问,Unsafe 相当于一个后门,基于该类可以直接操作特定内存的数据。Unsafe 类存在 sun.misc 包中,其内部方法操作可以像C的指针一样直接操作内存,因为 Java 中的 CAS 操作的执行依赖于 Unsafe 类的方法。
注意 Unsafe 类的所有方法都是 native 修饰的,也就是说 unsafe 类中的方法都可以直接调用操作系统底层资源执行相应的任务
Atomic修饰的包装类能够保证原子性的原因也是依靠的底层的 unsafe 类

2.变量valueOffset

valueOffset 所代表的是AtomicInteger对象的value成员变量在内存中的偏移量即地址(不要把对象地址和对象变量地址搞混了,对象可以有很多个变量,每一个变量都有其偏移量),因为Unsafe就是根据内存偏移地址获取数据的。我们可以简单的把valueOffset理解为value变量的内存当前值。也就是说,valueOffset 是当前AtomicInteger对象初始化时的原始值的内存地址

例如:AtomicInteger atomicInteger = new AtomicInteger(5); 这个5就是原始值,即valueOffset是5的内存地址而不是atomicInteger对象的内存地址。

3.变量value用volatile修饰

保证了多线程之间的内存可见性,也就是说原子类中存的真实的值对于各个线程都是可见的,这也是原子类能实现原子性的重要一点。

UnSafe.getAndAddInt()源码解释:

image-20221011104104097

var1:AtomicInteger对象本身。
var2:该对象变量值的引用地址。
var4:需要变动的数量。
var5:是用过var1,var2找出的主内存中真实的值(期望值),while循环如果是false即比较失败那么执行do方法体时每次都要从主内存拿到var2偏移量处变量的最新的值到自己的本地内存,然后执行 compareAndSwapInt()再和主内存的值进行比较。因为线程不可以直接越过高速缓存,直接操作主内存,所以执行上述方法需要比较一次,在执行加 1 操作

总的来说这个方法就是用该对象当前的value变量值(线程的工作内存)与var5期望的value变量值(主内存)比较:
如果相同,更新var5+var4并且返回true,
如果不同,继续取值然后再比较,直到更新完成。

下面再来举个例子:
假设线程A和线程B两个线程同时执行getAndAddInt操作(分别跑在不同CPU上) :

  1. Atomiclnteger里面的value原始值为3,即主内存中Atomiclnteger的value为3,根据JMM模型,线程A和线程B各自持有一份值为3的value的副本分别到各自的工作内存。
  2. 线程A通过getIntVolatile(var1, var2)拿到value值3,这时线程A被挂起。
  3. 线程B也通过getintVolatile(var1, var2)方法获取到value值3,此时刚好线程B没有被挂起并执行compareAndSwapInt方法比较内存值也为3,成功修改内存值为4,线程B打完收工,一切OK。
  4. 这时线程A恢复,执行compareAndSwapInt方法比较,发现自己手里的值数字3和主内存的值数字4不一致,说明该值己经被其它线程抢先一步修改过了,那A线程本次修改失败,只能重新读取重新来一遍了。
  5. 线程A重新获取value值,因为变量value被volatile修饰,所以其它线程对它的修改,线程A总是能够看到,线程A继续执行compareAndSwaplnt进行比较替换,直到成功。

为什么Unsafe类中的compareAndSwapInt方法能保证原子性呢?

因为它是一个本地方法,该方法的实现位于unsafe.cpp中。

UNSAFE_ENTRY(jboolean, Unsafe_CompareAndSwapInt(JNIEnv *env, jobject unsafe, jobject obj, jlong offset, jint e, jint x)
UnsafeWrapper("Unsafe_CompareAndSwaplnt");
oop p = JNlHandles::resolve(obj);
jint* addr = (jint *)index_oop_from_field_offset_long(p, offset);
return (jint)(Atomic::cmpxchg(x, addr, e))== e;
UNSAFE_END
//先想办法拿到变量value在内存中的地址。
//通过Atomic::cmpxchg实现比较替换,其中参数x是即将更新的值,参数e是原内存的值。

小结

CAS有3个操作数,内存值V,旧的预期值A,要修改的更新值B。
当且仅当预期值A和内存值V相同时,将内存值V修改为B,否则什么都不做。

CAS的使用场景
CAS缺点
	CAS 适用于读多写少的情况下,这样冲突一般较少;而synchronized 适用于写多读少的情况下,冲突一般较多。 

	>1. 对于资源竞争较少(线程冲突较轻)的情况,使用 synchronized 同步锁进行线程阻塞,唤醒切换,以及用户态内核态间的切换操作,都会额外消耗 cpu 资源;而 CAS 基于硬件实现,不需要进入内核,不需要切换线程,操作自旋几率较少,因此可以获得更高的性能。
	>2. 对于资源竞争严重(线程冲突严重)的情况,CAS 自旋的概率会比较大,从而浪费更多的 CPU 资源,效率低于 synchronized。

CAS 不加锁,保证一次性,但是需要多次比较

  • 循环时间长,开销大(因为执行的是 do while,如果比较不成功一直在循环,最差的情况,就是某个线程一直取到的值和预期值都不一样,这样就会无限循环,可能会给CPU带来很大的开销)
  • 只能保证一个共享变量的原子操作(因为是利用原子类进行操作)
    • 当对一个共享变量执行操作时,我们可以使用循环CAS的方式来保证原子操作。
    • 但是,对多个共享变量操作时,循环CAS就无法保证操作的原子性,这个时候就可以用锁来保证原子性。
  • 引出来ABA问题

原子类 AtomicInteger 的 ABA 问题

从 AtomicInteger 引出下面的问题
CAS -> Unsafe -> CAS 底层思想 -> ABA -> 原子引用更新 -> 如何规避 ABA 问题

ABA问题简介

CAS算法实现一个重要前提需要取出内存中某时刻的数据并在当下时刻比较并替换,那么在这个时间差类会导致数据的变化。

假设现在有两个线程,分别是T1和T2,然后T1执行某个操作的时间为 10 秒,T2 执行某个时间的操作是 2 秒,最开始 AB 两个线程,分别从主内存中获取 A值,但是因为 B 的执行速度更快,他先把 A 的值改成 B,然后在修改成 A,然后执行完毕,T1 线程在 10 秒后,执行完毕,判断内存中的值为 A,并且和自己预期的值一样,它就认为没有人更改了主内存中的值,就快乐的修改成 B,但是实际上可能中间经历了 ABCDEFA 这个变换,也就是中间的值发生了改变但是在最终时刻又变了回来。

尽管线程T1的CAS操作成功,但是不代表这个过程就是没有问题的。因为CAS 只管开头和结尾,也就是头和尾是一样,那就修改成功,中间的这个过程,可能会被人修改过,但是CAS并没有记录变量的状态的能力。

上述就是ABA问题,所以ABA问题就是在进行获取主内存值的时候,该内存值在我们写入主内存的时候,已经被修改了 N 次,但是最终又改成原来的值了

原子引用

原子引用(AtomicReference)其实和原子包装类是差不多的概念,就是将一个 java 类,用原子引用类进行包装起来,那么这个类就具备了原子性。

代码演示
import java.util.concurrent.atomic.AtomicReference;

class User{
	String userName;
	int age;
	
    public User(String userName, int age) {
		this.userName = userName;
		this.age = age;
	}
	@Override
	public String toString() {
		return String.format("User [userName=%s, age=%s]", userName, age);
	}
}

public class AtomicReferenceDemo {
    public static void main(String[] args){
        User z3 = new User( "z3",22);
        User li4 = new User("li4" ,25);
        // 创建原子引用包装类
		AtomicReference<User> atomicReference = new AtomicReference<>();
        // 现在主物理内存的共享变量,为 z3
        atomicReference.set(z3);
        // 比较并交换,如果现在主物理内存的值为 z3,那么交换成 l4
		System.out.println(atomicReference.compareAndSet(z3, li4)+"\t"+atomicReference.get().toString());
        // 比较并交换,现在主物理内存的值是 l4 了,但是预期为 z3,因此交换失败
        System.out.println(atomicReference.compareAndSet(z3, li4)+"\t"+atomicReference.get().toString());
    }
}

输出结果

true	User [userName=li4, age=25]
false	User [userName=li4, age=25]

AtomicStampedReference版本号原子引用

原子引用 + 新增一种机制,那就是修改版本号(类似时间戳),它可以用来解决ABA问题。

AtomicStampedReference(时间戳原子引用),来这里应用于版本号的更新,也就是每次更新的时候,需要比较期望值和当前值,以及期望版本号和当前版本号。

ABA问题的解决代码演示
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicReference;
import java.util.concurrent.atomic.AtomicStampedReference;

public class ABADemo {
	/**
	 * 普通的原子引用包装类
	 */
	static AtomicReference<Integer> atomicReference = new AtomicReference<>(100);
	// 时间戳原子引用:传递两个值,一个是初始值,一个是初始版本号
	static AtomicStampedReference<Integer> atomicStampedReference = new AtomicStampedReference<>(100, 1);

	public static void main(String[] args) {
		System.out.println("============以下是ABA问题的产生==========");
		new Thread(() -> {
			// 把100 改成 101 然后在改成100,也就是ABA
			atomicReference.compareAndSet(100, 101);
			atomicReference.compareAndSet(101, 100);
		}, "t1").start();

		new Thread(() -> {
			try {
				// 睡眠一秒,保证t1线程,完成了ABA操作
				TimeUnit.SECONDS.sleep(1);
			} catch (InterruptedException e) {
				e.printStackTrace();
			}
			// 把100 改成 101 然后在改成100,也就是ABA
			System.out.println(atomicReference.compareAndSet(100, 2019) + "\t" + atomicReference.get());
		}, "t2").start();

		try {
			TimeUnit.SECONDS.sleep(2);
		} catch (Exception e) {
			e.printStackTrace();
		}

		System.out.println("============以下是ABA问题的解决==========");
		new Thread(() -> {
			// 获取版本号
			int stamp = atomicStampedReference.getStamp();
			System.out.println(Thread.currentThread().getName() + "\t 第一次版本号" + stamp);
			// 暂停t3一秒钟
			try {
				TimeUnit.SECONDS.sleep(1);
			} catch (InterruptedException e) {
				e.printStackTrace();
			}
			// 传入4个值,期望值,更新值,期望版本号,更新版本号
			atomicStampedReference.compareAndSet(100, 101, atomicStampedReference.getStamp(),
					atomicStampedReference.getStamp() + 1);
			System.out.println(Thread.currentThread().getName() + "\t 第二次版本号" + atomicStampedReference.getStamp());
			atomicStampedReference.compareAndSet(101, 100, atomicStampedReference.getStamp(),
					atomicStampedReference.getStamp() + 1);
			System.out.println(Thread.currentThread().getName() + "\t 第三次版本号" + atomicStampedReference.getStamp());
		}, "t3").start();

		new Thread(() -> {
			// 获取版本号
			int stamp = atomicStampedReference.getStamp();
			System.out.println(Thread.currentThread().getName() + "\t 第一次版本号" + stamp);
			// 暂停t4 3秒钟,保证t3线程也进行一次ABA问题
			try {
				TimeUnit.SECONDS.sleep(3);
			} catch (InterruptedException e) {
				e.printStackTrace();
			}
			boolean result = atomicStampedReference.compareAndSet(100, 2019, stamp, stamp + 1);
			System.out.println(Thread.currentThread().getName() + "\t 修改成功否:" + result + "\t 当前最新实际版本号:"
					+ atomicStampedReference.getStamp());
			System.out.println(Thread.currentThread().getName() + "\t 当前实际最新值" + atomicStampedReference.getReference());
		}, "t4").start();
	}
}

输出结果

============以下是ABA问题的产生==========
true	2019
============以下是ABA问题的解决==========
t3	 第一次版本号1
t4	 第一次版本号1
t3	 第二次版本号2
t3	 第三次版本号3
t4	 修改成功否:false	 当前最新实际版本号:3
t4	 当前实际最新值100

我们能够发现,线程 t3,在进行 ABA 操作后,版本号变更成了 3,而线程 t4 在进行操作的时候,就出现操作失败了,因为版本号和当初拿到的不一样。

Collection的非线程安全问题

ArrayList的非线程安全问题

首先回顾一下ArrayList集合类

当我们执行new ArrayList();语句时,底层实际上创建了一个空的数组,伴随着初始容量值为 10。
当执行 add 方法后,如果超过了 10,那么会进行扩容,扩容的大小为原值的一半(右移一位得到的一半),也就是 5 个,使用下列方法扩容。这里我不懂为什么空的数组还有初始容量值所以debug了一下ArrayList实例创建过程。具体过程如下:

ArrayList底层数组创建过程源码分析

第一步会进入构造方法创建出底层数组,这里是创建了一个空数组。只要我们不向里面添加元素,它就一直是一个空数组。

public ArrayList() {
    this.elementData = DEFAULTCAPACITY_EMPTY_ELEMENTDATA;//变量名告诉了我们它是一个空数组
}

第二步当我们调用add方法向里面添加元素时,它才会真正的进行初始化。

public boolean add(E e) {
    ensureCapacityInternal(size + 1);  // size为int类型,所以默认值为0
    elementData[size++] = e;//这一步只有数组真正初始化之后才会进行,因为数组默认是空数组
    return true;
}

第三步进入ensureCapacityInternal方法,参数为1,因为第一次添加元素

private void ensureCapacityInternal(int minCapacity) {
    ensureExplicitCapacity(calculateCapacity(elementData, minCapacity));
}

第四步先进入calculateCapacity方法,因为它的返回值是下一个方法的参数。这里这个方法的第一个参数就是ArrayList的底层数组,这里仍然是一个空数组,第二个参数仍然是1。

private static int calculateCapacity(Object[] elementData, int minCapacity) {
    if (elementData == DEFAULTCAPACITY_EMPTY_ELEMENTDATA) {//这里会进行一个判断,第一次添加元素时,一定是空数组所以进入if方法体,DEFAULT_CAPACITY就是我们一开始说到的数组初始容量值=10,这里是返回最大者,所以返回10
        return Math.max(DEFAULT_CAPACITY, minCapacity);
    }
    return minCapacity;
}

第五步进入ensureExplicitCapacity方法,参数为10(数组初始化容量)这里会判断一下是否需要扩容,很明显if条件是true,数组是个空数组需要扩容。

private void ensureExplicitCapacity(int minCapacity) {
    modCount++;//集合中一种安全检查机制
    if (minCapacity - elementData.length > 0)
        grow(minCapacity);
}

第六步进入扩容方法。具体每一步执行结果都标在里面了,然后进入数组拷贝方法

private void grow(int minCapacity) {//10
    // overflow-conscious code
    int oldCapacity = elementData.length;//0
    int newCapacity = oldCapacity + (oldCapacity >> 1);//0,这里默认扩容大小为原数组大小的1.5倍,0.5是数组大小位数右移一位得到。
    if (newCapacity - minCapacity < 0)//true
        newCapacity = minCapacity;//10
    if (newCapacity - MAX_ARRAY_SIZE > 0)//false
        newCapacity = hugeCapacity(minCapacity);
    // minCapacity is usually close to size, so this is a win:
    elementData = Arrays.copyOf(elementData, newCapacity);
}

最后一步就是数组拷贝了,参数一是空数组,参数而是数组初始容量大小10,这一步执行完之后,add整个操作就进行完了,第一次add时会将底层数组真正的创建出来,之后只有在需要扩容时才会继续执行这些过程。

public static <T> T[] copyOf(T[] original, int newLength) {
    return (T[]) copyOf(original, newLength, original.getClass());
}

下面我们进入正题,我们都知道ArrayList是非线程安全的,以往我们都是在单线程环境下操作ArrayList所以感觉不到这一点。但一旦进入多线程,其非线程安全问题就随之而来了。因为ArrayList在进行写操作的时候,add方法上为了保证并发性,是没有添加 synchronized 关键字进行修饰的,所以并发写的时候,就会出现问题。

ArrayList非线程安全问题代码演示
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.UUID;
import java.util.Vector;

public class ArrayListNotSafeDemo {
	public static void main(String[] args) {
        List<String> list = new ArrayList<>();
        //List<String> list = new Vector<>();
        //List<String> list = Collections.synchronizedList(new ArrayList<>());
        for (int i = 0; i < 30; i++) {
            new Thread(() -> {
                list.add(UUID.randomUUID().toString().substring(0, 8));
                System.out.println(list);
            }, String.valueOf(i)).start();
        }
	}
}

程序执行的结果就是会抛出java.util.ConcurrentModificationException并发修改异常。

第一种解决方法就是使用Vector这种线程安全的类,它在addElement方法上加了锁即用了synchronized关键字进行修饰。这样每次就只能够让一个线程进行操作,所以不会出现线程不安全的问题,但是因为加锁了,导致并发性基于下降,所以我们实际生产中不考虑这种方法。

第二种解决方法是使用Collections集合工具类,在 ArrayList外面包装一层同步机制。

List<String> list = Collections.synchronizedList(new ArrayList<>());

第三种解决方法是CopyOnWriteArrayList意思是写时复制的ArrayList

CopyOnWrite容器即写时复制的容器,主要是一种读写分离的思想。写时复制就是待一个容器添加元素的时候,不直接往当前容器Object[]添加,而是先将当前容器Object[]进行copy,复制出一个新的容器Object[] newELements,然后向新的容器Object[ ] newELements里添加元素,添加完元素之后,再将原容器的引用指向新的容器setArray (newELements)。
这样做的好处是可以对CopyOnWrite容器进行并发的读,而不需要加锁(区别于Vector和Collections.synchronizedList()),因为当前容器不会添加任何元素(写操作在复制的容器中进行)。这就是CopyOnWrite容器读写分离思想的体现,读操作和写操作是不同的容器。
具体到CopyOnWriteArrayList上就是写的时候,把 ArrayList复制一个出来,然后把新增的元素写到扩容出来的ArrayList中,在通知其他的线程,把原来ArrayList 的引用指向复制的ArrayList。
CopyOnWriteArrayList的add源码如下:

public boolean add(E e) {
        final ReentrantLock lock = this.lock;//首先需要加锁
        lock.lock();
        try {
            //然后复制一个数组出来
            Object[] elements = getArray();
            int len = elements.length;
            Object[] newElements = Arrays.copyOf(elements, len + 1);
            newElements[len] = e;//在复制后的数组中写入需要add的内容
            setArray(newElements);//最后把复制后的数组set到CopyOnWriteArrayList实例中,Array就是CopyOnWriteArrayList中的底层数组
            return true;
        } finally {
            lock.unlock();
        }
}

HashSet的非线程安全问题

HashSet也是非线性安全的。HashSet内部是包装了一个HashMap的,阅读HashSet源码,我们在发现使用HashSet无参构造方法时,其实际上是创建了一个泛型的HashMap,且HashSet本身就是一个泛型类,HashMap其实也是一个泛型类,但在HashSet源码中写死了其第二个代表value的泛型为Object,因为Set的本质就是Map的key。我们如果在创建HashSet时指明了其泛型类型,那么就相当于指明了其内部的HashMap的key的泛型。

但是为什么我调用 HashSet.add()的方法,只需要传递一个元素,但真正的HashMap应该是需要传递 key-value 键值对的呢?再次阅读源码,我们可以发现HashSet的add方法中真的是使用的HashMap的add实例方法,不过其value传入的是一个Object对象常量,所以我们在使用HashSet,向其添加元素时只需要传入一个key,而不需要传入value了。HashSet中其他方法也是如此,可以类比add方法。

HashSet源码如下:
public class HashSet<E> extends AbstractSet<E> implements Set<E>, Cloneable, java.io.Serializable
{
    static final long serialVersionUID = -5024744406713321676L;
    private transient HashMap<E,Object> map;
    // Dummy value to associate with an Object in the backing Map
    private static final Object PRESENT = new Object();
    /**
     * Constructs a new, empty set; the backing <tt>HashMap</tt> instance has
     * default initial capacity (16) and load factor (0.75).
     */
    public HashSet() {
        map = new HashMap<>();
    }
    public boolean add(E e) {
        return map.put(e, PRESENT)==null;
    }
}
HashSet非线程安全问题代码演示
import java.util.Collections;
import java.util.HashSet;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.CopyOnWriteArraySet;

public class SetNotSafeDemo {
	public static void main(String[] args) {
		Set<String> set = new HashSet<>();
		//Set<String> set = Collections.synchronizedSet(new HashSet<>());
		//Set<String> set = new CopyOnWriteArraySet<String>();
        for (int i = 0; i < 30; i++) {
            new Thread(() -> {
                set.add(UUID.randomUUID().toString().substring(0, 8));
                System.out.println(set);
            }, String.valueOf(i)).start();
        }
	}	
}

HashSet的非线程安全问题的解决方法:

  1. Collections.synchronizedSet(new HashSet<>())
  2. CopyOnWriteArraySet<>()(推荐)

CopyOnWriteArraySet源码一览:
public class CopyOnWriteArraySet<E> extends AbstractSet<E>
        implements java.io.Serializable {
    private static final long serialVersionUID = 5457747651344034263L;

    private final CopyOnWriteArrayList<E> al;

    /**
     * Creates an empty set.
     */
    public CopyOnWriteArraySet() {
        al = new CopyOnWriteArrayList<E>();//可看出CopyOnWriteArraySet包装了一个CopyOnWriteArrayList
    }

    public CopyOnWriteArraySet(Collection<? extends E> c) {
        if (c.getClass() == CopyOnWriteArraySet.class) {
            @SuppressWarnings("unchecked") CopyOnWriteArraySet<E> cc =
                (CopyOnWriteArraySet<E>)c;
            al = new CopyOnWriteArrayList<E>(cc.al);
        }
        else {
            al = new CopyOnWriteArrayList<E>();
            al.addAllAbsent(c);
        }
    }
 
    public boolean add(E e) {
        return al.addIfAbsent(e);
    }
    
    public boolean addIfAbsent(E e) {
        Object[] snapshot = getArray();
        return indexOf(e, snapshot, 0, snapshot.length) >= 0 ? false :
            addIfAbsent(e, snapshot);
    }
    
    //暴力查找
    private static int indexOf(Object o, Object[] elements,
                               int index, int fence) {
        if (o == null) {
            for (int i = index; i < fence; i++)
                if (elements[i] == null)
                    return i;
        } else {
            for (int i = index; i < fence; i++)
                if (o.equals(elements[i]))
                    return i;
        }
        return -1;
    }

    private boolean addIfAbsent(E e, Object[] snapshot) {
        final ReentrantLock lock = this.lock;
        lock.lock();
        try {
            Object[] current = getArray();
            int len = current.length;
            if (snapshot != current) {//还要检查多一次元素存在性,生怕别的线程已经插入了
                // Optimize for lost race to another addXXX operation
                int common = Math.min(snapshot.length, len);
                for (int i = 0; i < common; i++)
                    if (current[i] != snapshot[i] && eq(e, current[i]))
                        return false;
                if (indexOf(e, current, common, len) >= 0)
                        return false;
            }
            Object[] newElements = Arrays.copyOf(current, len + 1);
            newElements[len] = e;
            setArray(newElements);
            return true;
        } finally {
            lock.unlock();
        }
    } 
}

HashMap的非线程安全问题

同理 HashMap 在多线程环境下,也是不安全的

import java.util.Collections;
import java.util.HashMap;
import java.util.Hashtable;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;

public class MapNotSafeDemo {
	public static void main(String[] args) {
        Map<String, String> map = new HashMap<>();
//      Map<String, String> map = Collections.synchronizedMap(new HashMap<>());
//		Map<String, String> map = new ConcurrentHashMap<>();
//		Map<String, String> map = new Hashtable<>();
        for (int i = 0; i < 30; i++) {
            new Thread(() -> {
                map.put(Thread.currentThread().getName(), UUID.randomUUID().toString().substring(0, 8));
                System.out.println(map);
            }, String.valueOf(i)).start();
        }
	}
}

解决方法:

  1. HashTable
  2. Collections.synchronizedMap(new HashMap<>())
  3. ConcurrentMap<>()(推荐)
    Map<String, String> map = new ConcurrentHashMap<>();

值传递和引用传递

记住一句话:在Java中所有参数传递都是值传递,不是引用传递。基本类型传的是数据值,引用类型传的是地址值。

基本数据类型是在栈上分配内存的(实际存储在方法栈帧中的局部变量表中),引用数据类型是在堆上分配内存的。

class Person {
    private Integer id;
    private String personName;

    public Person(String personName) {
        this.personName = personName;
    }

	public Integer getId() {
		return id;
	}

	public void setId(Integer id) {
		this.id = id;
	}

	public String getPersonName() {
		return personName;
	}

	public void setPersonName(String personName) {
		this.personName = personName;
	}
}

public class TransferValueDemo {
    public void changeValue1(int age) {
        age = 30;
    }

    public void changeValue2(Person person) {
        person.setPersonName("XXXX");
    }
    public void changeValue3(String str) {
        str = "XXX";
    }

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

        // 定义基本数据类型
        int age = 20;
        test.changeValue1(age);
        System.out.println("age ----" + age);

        // 实例化person类
        Person person = new Person("abc");
        test.changeValue2(person);
        System.out.println("personName-----" + person.getPersonName());

        // String
        String str = "abc";
        test.changeValue3(str);
        System.out.println("string-----" + str);

    }
}

输出结果:

age ----20
personName-----XXXX
string-----abc

这里主要说一下最后一个String测试,main线程中创建了一个String类型的变量str指向字符串常量池中的abc字符串,然后调用changeValue3方法,此时把str变量中的引用地址传了过去。那么changeValue3方法栈帧中的str变量就会接收这个引用地址,之后changeValue3方法体中给str这个变量又赋了一个字符串XXX,注意这里是把XXX字符串对象的引用地址赋给了它,而不是修改原来字符串的内容,然后方法结束,栈帧自动回收,那么main方法栈帧中的str变量的引用仍然指向abc这个字符串对象。

Java锁之公平锁和非公平锁

公平锁和非公平锁概念

公平锁是指多个线程按照申请锁的顺序来获取锁,类似排队打饭,先来后到。

非公平锁是指多个线程获取锁的顺序并不是按照申请锁的顺序,有可能后申请的线程比先申请的线程优先获取锁。在高并发的情况下,有可能会造成优先级反转或者饥饿现象(也就是某个线程一直得不到锁)

并发包中ReentrantLock的创建可以指定构造函数的boolean类型来得到公平锁或非公平锁,默认是非公平锁。当设置为true时,在线程争用下,锁有利于向等待时间最长的线程授予访问权限。设置为false时,此锁不保证任何特定的访问顺序。与使用默认设置的程序相比,使用由许多线程访问的公平锁的程序可能显示出较低的总体吞吐量(即,较慢;通常要慢得多),但是在获得锁和保证没有饥饿的时间上差异较小。

但是请注意,锁的公平性并不能保证线程调度的公平性。因此,使用公平锁的多个线程中的一个线程可以连续多次获得公平锁(一个线程在某一节点上连续调用导致其等待队列上连续几个线程都是此线程),而其他活动线程则没有进行并且当前没有持有该锁。还要注意,不计时的 tryLock()方法不支持公平性设置。如果锁可用,即使其他线程正在等待,它也会成功。

两者区别

公平锁: 公平锁就是很公平,在并发环境中,每个线程在获取锁时会先查看此锁维护的等待队列,如果为空,或者当前线程是等待队列的第一个,就占有锁,否则就会加入到等待队列中,以后会按照FIFO的规则从队列中取到自己。
非公平锁: 非公平锁比较粗鲁,上来就直接尝试占有锁,如果尝试失败,就再采用类似公平锁的方式来获取锁。

对于ReentrantLock而言,通过构造函数指定该锁是否是公平锁,默认是非公平锁。非公平锁的优点在于吞吐量比公平锁大。对于Synchronized而言,其也是一种非公平锁

Java锁之可重入锁和递归锁 ReentrantLock

可重入锁和递归锁概念

可重入锁(也叫做递归锁)指的是同一线程外层函数获得锁之后,内层递归函数仍然能获取该锁的代码,在同一个线程在外层方法获取锁的时候,在进入内层方法会自动获取锁。

也就是说,线程可以进入任何一个它已经拥有的锁所同步着的代码块。ReentrantLock/synchronized就是一个典型的可重入锁。

可重入锁最大的作用是避免死锁

可重入锁和递归锁代码演示

可重入锁就是,在一个 method1 方法中加入一把锁,方法1方法体内部的方法 2 也加锁了,那么他们拥有的是同一把锁。也就是说我们只要进入 method1 后,那么它也能直接进入 method2 方法,因为他们所拥有的锁是同一把锁。

Synchronized可重入锁代码演示
class Phone {
    public synchronized void sendSMS() throws Exception{
        System.out.println(Thread.currentThread().getName() + "\t invoked sendSMS()");
        // 在同步方法中,调用另外一个同步方法
        sendEmail();
    }

    public synchronized void sendEmail() throws Exception{
        System.out.println(Thread.currentThread().getId() + "\t invoked sendEmail()");
    }
}

public class SynchronizedReentrantLockDemo {
	public static void main(String[] args) {
        Phone phone = new Phone();
        // 两个线程操作资源列
        new Thread(() -> {
            try {
                phone.sendSMS();
            } catch (Exception e) {
                e.printStackTrace();
            }
        }, "t1").start();
        new Thread(() -> {
            try {
                phone.sendSMS();
            } catch (Exception e) {
                e.printStackTrace();
            }
        }, "t2").start();
	}
}

上面的代码中我们编写了一个资源类 phone,拥有两个加了 synchronized 的同步方法,分别是 sendSMS 和 sendEmail,我们在sendSMS 方法中,调用 sendEmail。最后在主线程同时开启了两个线程进行测试,最后输出结果如下,可以发现当t1线程进入sendSMS的时候,会拥有一把锁(phone对象锁),同时 t2 线程无法进入,直到 t1 线程拿着锁,执行了接着执行了sendEmail方法后,才释放锁,这样 t2才能够进入。

t1	 invoked sendSMS()		t1线程在执行外层方法时会去获取锁
11	 invoked sendEmail()	t1在进入内层方法会自动获取执行外层方法时获取的锁
t2	 invoked sendSMS()		t2线程在执行外层方法时会去获取锁
12	 invoked sendEmail()	t2在进入内层方法会自动获取执行外层方法时获取的锁
1234
ReentrantLock可重入锁演示程序
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

class Phone2 implements Runnable{
    Lock lock = new ReentrantLock();
    /**
     * set进去的时候,就加锁,调用set方法的时候,能否访问另外一个加锁的set方法
     */
    public void getLock() {
        lock.lock();
        try {
            System.out.println(Thread.currentThread().getName() + "\t get Lock");
            setLock();
        } finally {
            lock.unlock();
        }
    }
    public void setLock() {
        lock.lock();
        try {
            System.out.println(Thread.currentThread().getName() + "\t set Lock");
        } finally {
            lock.unlock();
        }
    }
    @Override
    public void run() {
        getLock();
    }
}
public class ReentrantLockDemo {
    public static void main(String[] args) {
        Phone2 phone = new Phone2();
        /**
         * 因为Phone实现了Runnable接口
         */
        Thread t3 = new Thread(phone, "t3");
        Thread t4 = new Thread(phone, "t4");
        t3.start();
        t4.start();
    }
}

现在我们对ReentrantLock进行验证,首先资源类实现了 Runnable 接口,重写了Run 方法,里面调用了get 方法,get 方法在进入的时候,就加了锁,然后在方法里面,又调用另外一个加了锁的 setLock 方法。最后输出结果我们能发现,结果和加 synchronized 方法是一致的,都是在外层的方法获取锁之后,线程能够直接进入内层方法。
输出结果

t3	 get Lock	t3线程在执行外层方法get时会去获取锁
t3	 set Lock	t3在进入内层方法set会自动获取执行外层方法get时获取的锁
t4	 get Lock	t4线程在执行外层方法get时会去获取锁	
t4	 set Lock	t4在进入内层方法set会自动获取执行外层方法get时获取的锁
加锁与解锁的拓展

那么如果我们在 getLock 方法加两把锁会是什么情况呢?

public void getLock() {
        lock.lock();
        lock.lock();
        try {
            System.out.println(Thread.currentThread().getName() + "\t get Lock");
            setLock();
        } finally {
            lock.unlock();
            lock.unlock();
        }
}

最后得到的结果也是一样的,因为里面不管有几把锁,其它他们都是同一把锁,也就是说用同一个钥匙都能够打开。
但是当我们在getLock方法中加两把锁,但是只解一把锁会出现什么情况呢?

public void getLock() {
        lock.lock();
        lock.lock();
        try {
            System.out.println(Thread.currentThread().getName() + "\t get Lock");
            setLock();
        } finally {
            lock.unlock();
        }
}

得到执行结果

t3get Lock
t3set Lock

也就是说程序直接卡死,线程不能出来,因为还有一把对象锁没有解开,线程无法退出这个方法,那么它就会等待这个锁直到解开。也就说明我们申请几把锁,最后需要解除几把锁
那么当我们只加一把锁,但是用两把锁来解锁的时候,又会出现什么情况呢?

public void getLock() {
        lock.lock();
        try {
            System.out.println(Thread.currentThread().getName() + "\t get Lock");
            setLock();
        } finally {
            lock.unlock();
            lock.unlock();
        }
}

这个时候,运行程序会直接报错。也就是说可以允许有锁没有被揭开,以为它有解开的可能。但是不允许没锁而去强行解锁。

t3get Lock
t3set Lock
t4get Lock
t4set Lock
Exception in thread "t3" Exception in thread "t4" java.lang.IllegalMonitorStateException
at java.util.concurrent.locks.ReentrantLock$Sync.tryRelease(ReentrantLock.java:151)
at java.util.concurrent.locks.AbstractQueuedSynchronizer.release(AbstractQueuedSynchronizer.java:1261)
at java.util.concurrent.locks.ReentrantLock.unlock(ReentrantLock.java:457)
at com.moxi.interview.study.thread.Phone.getLock(ReenterLockDemo.java:52)
at com.moxi.interview.study.thread.Phone.run(ReenterLockDemo.java:67)
at java.lang.Thread.run(Thread.java:745)
java.lang.IllegalMonitorStateException
at java.util.concurrent.locks.ReentrantLock$Sync.tryRelease(ReentrantLock.java:151)
at java.util.concurrent.locks.AbstractQueuedSynchronizer.release(AbstractQueuedSynchronizer.java:1261)
at java.util.concurrent.locks.ReentrantLock.unlock(ReentrantLock.java:457)
at com.moxi.interview.study.thread.Phone.getLock(ReenterLockDemo.java:52)
at com.moxi.interview.study.thread.Phone.run(ReenterLockDemo.java:67)
at java.lang.Thread.run(Thread.java:745)

Java锁之自旋锁

自旋锁概念

自旋锁(Spin Lock)是指尝试获取锁的线程如果没有获取到锁的话不会立即阻塞,而是采用循环的方式去尝试获取锁,这样的好处是减少线程上下文切换的消耗因为自旋锁会循环比较获取锁直到成功获取到锁为止,没有类似于 wait 的阻塞;缺点是当不断自旋的线程越来越多的时候,会因为执行 while 循环不断的消耗 CPU。比如原来提到的比较并交换(CAS),底层使用的就是自旋,自旋就是多次尝试,多次访问,不会阻塞的状态就是自旋。

这里我们介绍一下自旋锁的由来:

在深入理解JVM一书中提到了互斥同步对性能最大的影响阻塞的实现,挂起线程和恢复线程的操作都需要转入内核态完成,这些操作给系统的并发性能带来了很大的压力。同时,虚拟机的开发团队也注意到在许多应用上,共享数据的锁定状态只会持续很短的一段时间,为了这段时间去挂起和恢复线程并不值得。如果物理机器有一个以上的处理器,能让两个或以上的线程同时并行执行,我们就可以让后面请求锁的那个线程 “稍等一下”,但不放弃处理器的执行时间,看看持有锁的线程是否很快就会释放锁。为了让线程等待,我们只需让线程执行一个忙循环(自旋),这项技术就是所谓的自旋锁。

自旋锁代码验证

通过 CAS 操作完成自旋锁,A 线程先进来调用 myLock 方法自己持有锁 5 秒,B随后进来发现当前有线程持有锁,不是 null,所以只能通过自旋等待,直到 A 释放锁后 B 随后抢到

import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicReference;

public class SpinLockDemo {
    // 现在的泛型装的是Thread,原子引用线程
    AtomicReference<Thread>  atomicReference = new AtomicReference<>();
    public void myLock() {
        // 获取当前进来的线程
        Thread thread = Thread.currentThread();
        System.out.println(Thread.currentThread().getName() + "\t come in ");
        // 开始自旋,期望值是null,更新值是当前线程,如果是null,则更新为当前线程,否则进行自旋
        while(!atomicReference.compareAndSet(null, thread)) {
        }
    }
    public void myUnLock() {
        // 获取当前进来的线程
        Thread thread = Thread.currentThread();
        // 自己用完了后,把atomicReference变成null
        atomicReference.compareAndSet(thread, null);
        System.out.println(Thread.currentThread().getName() + "\t invoked myUnlock()");
    }
    public static void main(String[] args) {
        SpinLockDemo spinLockDemo = new SpinLockDemo();
        // 启动t1线程,开始操作
        new Thread(() -> {
            // 开始占有锁
            spinLockDemo.myLock();
            try {
                TimeUnit.SECONDS.sleep(5);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            // 开始释放锁
            spinLockDemo.myUnLock();
        }, "t1").start();
        // 让main线程暂停1秒,使得t1线程,先执行
        try {
            TimeUnit.SECONDS.sleep(1);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        // 1秒后,启动t2线程,开始占用这个锁
        new Thread(() -> {
            // 开始占有锁
            spinLockDemo.myLock();
            // 开始释放锁
            spinLockDemo.myUnLock();
        }, "t2").start();
    }
}

首先输出的是 t1 come in然后 1 秒后,t2 线程启动,发现锁被 t1 占有,然后t2线程不断的执行 compareAndSet方法来进行比较直到 t1 释放锁后,也就是 5 秒后,t2 成功获取到锁然后释放,具体执行输出结果如下

t1	 come in 
.....一秒后.....	
t2	 come in 
.....五秒后.....
t1	 invoked myUnlock()
t2	 invoked myUnlock()

独占锁(写锁) / 共享锁(读锁) / 互斥锁

概念

**独占锁:**指该锁一次只能被一个线程所持有。ReentrantLock和Synchronized都是独占锁。
**共享锁:**指该锁可以被多个线程锁持有
对ReentrantReadWriteLock来说其读锁是共享锁,其写锁是独占锁。即写的时候只能一个人写,但是读的时候,可以多个人同时读。

为什么会有写锁和读锁

原来我们使用 ReentrantLock 创建锁的时候,是独占锁,也就是说一次只能一个线程访问,但是有一个读写分离场景,读的时候想同时进行,那么原来独占锁的并发能力就会下降,因为读锁并不会造成数据不一致的问题,因此可以多个人共享读。多个线程同时读一个资源类是没有任何问题的,所以为了满足并发量,读取共享资源应该可以同时进行,但是如果一个线程想去写共享资源,就不应该再有其它线程可以对该资源进行读或写
读-读: 能共存
读-写: 不能共存
写-写: 不能共存

即读写,写读,写写的过程是互斥的。

不加锁的非线程安全问题代码演示

实现一个读写缓存的操作,假设开始没有加锁的时候,会出现什么情况

import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.Lock;
/**
 * 资源类
 */
class MyCache {
    private volatile Map<String, Object> map = new HashMap<>();
    // private Lock lock = null;
    /**
     * 定义写操作
     * 满足:原子 + 独占
     * @param key
     * @param value
     */
    public void put(String key, Object value) {
        System.out.println(Thread.currentThread().getName() + "\t 正在写入:" + key);
        try {
            // 模拟网络拥堵,延迟 0.3 秒
            TimeUnit.MILLISECONDS.sleep(300);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        map.put(key, value);
        System.out.println(Thread.currentThread().getName() + "\t 写入完成");
    }
    public void get(String key) {
        System.out.println(Thread.currentThread().getName() + "\t 正在读取:");
        try {
            // 模拟网络拥堵,延迟 0.3 秒
            TimeUnit.MILLISECONDS.sleep(300);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        Object value = map.get(key);
        System.out.println(Thread.currentThread().getName() + "\t 读取完成:" + value);
    }
}
public class ReadWriteLockDemo {
    public static void main(String[] args) {
        MyCache myCache = new MyCache();
        // 线程操作资源类,5 个线程写
        for (int i = 0; i < 5; i++) {
            // lambda 表达式内部必须是 final
            final int tempInt = i;
            new Thread(() -> {
                myCache.put(tempInt + "", tempInt + "");
            }, String.valueOf(i)).start();
        }
        // 线程操作资源类, 5 个线程读
        for (int i = 0; i < 5; i++) {
            // lambda 表达式内部必须是 final
            final int tempInt = i;
            new Thread(() -> {
                myCache.get(tempInt + "");
            }, String.valueOf(i)).start();
        }
    }
}

最后运行结果:

0正在写入:0
4正在写入:4
3正在写入:3
1正在写入:1
2正在写入:2
0正在读取:
1正在读取:
2正在读取:
3正在读取:
4正在读取:
2写入完成
4写入完成
4读取完成:null
0写入完成
3读取完成:null
0读取完成:null
1写入完成
3写入完成
1读取完成:null
2读取完成:null

我们可以看到,在写入的时候,写操作都被其它线程打断了,这就造成了某个线程还没写完,其它线程又开始写或者读,这样就造成了数据不一致,全是null的情况就是所有线程都还没有写入时某些线程就已经读过了。
解决方法
上面的代码是没有加锁的,这样就会造成线程在进行写入操作的时候,被其它线程频繁打断,从而不具备原子性,这个时候,我们就需要用到读写锁来解决了

**ReentrantReadWriteLock(重入读写锁)**这里的读锁和写锁的区别在于,写锁一次只能一个线程进入,执行写操作,而读锁是多个线程能够同时进入,进行读取的操作

加读写锁后代码演示

import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
import java.util.concurrent.locks.ReentrantReadWriteLock;
/**
 * 资源类
 */
class MyCache {
    /**
     * 缓存中的东西,必须保持可见性,因此使用 volatile 修饰
     */
    private volatile Map<String, Object> map = new HashMap<>();
    /**
     * 创建一个读写锁
     * 它是一个读写融为一体的锁,在使用的时候,需要转换
     */
    private ReentrantReadWriteLock rwLock = new ReentrantReadWriteLock();
    /**
     * 定义写操作
     * 满足:原子 + 独占
     * @param key
     * @param value
     */
    public void put(String key, Object value) {
		// 创建一个写锁
        rwLock.writeLock().lock();
        try {
            System.out.println(Thread.currentThread().getName() + "\t正在写入:" + key);
            try {
				// 模拟网络拥堵,延迟 0.3 秒
                TimeUnit.MILLISECONDS.sleep(300);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            map.put(key, value);
            System.out.println(Thread.currentThread().getName() + "\t写入完成");
        } catch (Exception e) {
            e.printStackTrace();
        } finally {
			// 写锁 释放
            rwLock.writeLock().unlock();
        }
    }
    /**
     * 获取
     * @param key
     */
    public void get(String key) {
		// 读锁
        rwLock.readLock().lock();
        try {
            System.out.println(Thread.currentThread().getName() + "\t正在读取:");
            try {
				// 模拟网络拥堵,延迟 0.3 秒
                TimeUnit.MILLISECONDS.sleep(300);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            Object value = map.get(key);
            System.out.println(Thread.currentThread().getName() + "\t读取完成:" + value);
        } catch (Exception e) {
            e.printStackTrace();
        } finally {
			// 读锁释放
            rwLock.readLock().unlock();
        }
    }
    /**
     * 清空缓存
     */
    public void clean() {
        map.clear();
    }
}
public class ReadWriteLockDemo {
    public static void main(String[] args) {
        MyCache myCache = new MyCache();
		// 线程操作资源类,5 个线程写
        for (int i = 1; i <= 5; i++) {
		// lambda 表达式内部必须是 final
            final int tempInt = i;
            new Thread(() -> {
                myCache.put(tempInt + "", tempInt + "");
            }, String.valueOf(i)).start();
        }
		// 线程操作资源类, 5 个线程读
        for (int i = 1; i <= 5; i++) {
		// lambda 表达式内部必须是 final
            final int tempInt = i;
            new Thread(() -> {
                myCache.get(tempInt + "");
            }, String.valueOf(i)).start();
        }
    }
}

运行结果:

1正在写入:1
1写入完成
2正在写入:2
2写入完成
3正在写入:3
3写入完成
4正在写入:4
4写入完成
5正在写入:5
5写入完成
2正在读取:
3正在读取:
1正在读取:
4正在读取:
5正在读取:
2读取完成:2
1读取完成:1
4读取完成:4
3读取完成:3
5读取完成:5

如果我们只开启写锁而不开启读锁的话,则写的时候仍然可以执行读操作,那么仍然会返回null,只有加上读锁,才会让其他线程都等待写操作全部完成。

乐观锁和悲观锁

概念及其实现方式

乐观锁: 顾名思义,就是十分乐观,它总是认为不会出现问题,无论干什么都不去上锁,如果出现了问题,再次更新值测试,这里使用了 version 字段。也就是每次更新的时候同时维护一个 version 字段。比如CAS自旋锁就是很典型的乐观锁,它总是觉得不会出问题,出问题了就进入循环。
乐观锁实现方式

  • 取出记录时,获取当前的 version
  • 更新时,带上这个 version
  • 执行更新时,set version = newVersion where version = oldVersion
  • 如果 version 不对,就更新失败

乐观锁:1:先查询,获得版本号 version =1
– A 线程
update user set name = “xjx”, version = version + 1 where id = 2 and version = 1
– B 线程抢先完成,这个时候 Version = 2,导致 A 修改失败
update user set name = “xjx”, version = version + 1where id = 2 and version = 1

MybatisPlus 使用乐观锁

首先需要在数据库增加 version字段,默认为 1
然后在实体类增加对应的字段
// 乐观锁 Version 注解

@Version
private Integer version;

注册组件,在 MybatisPlusConfig 中配置
// 注册乐观锁

@Bean
public OptimisticLockerInterceptor optimisticLockerInterceptor() {
  return new OptimisticLockerInterceptor();
}

悲观锁
顾名思义,就是十分悲观,它总是认为什么时候都会出现问题,无论什么操作都会上锁,再次操作,很典型的就是synchronized,它就是悲观锁,总是认为会出问题,所以加了synchronized关键字的方法一次都只允许一个线程进行执行。

为什么 Synchronized 无法禁止指令重排,却能保证有序性

要想回答好这个问题,首先我们要知道以下的概念:

  • Java 内存模型
  • 并发编程有序性问题
  • 指令重排
  • synchronized 锁
  • 可重入锁
  • 排它锁
  • as-if-serial 语义
  • 单线程&多线程

标准解答

先解释什么是有序性问题,然后要知道是什么原因导致的有序性问题。

为了进一步提升计算机各方面能力,在硬件层面做了很多优化,如处理器优化和指令重排等,但是这些技术的引入就会导致我们写的代码逻辑可能与最后编译之后的代码逻辑不同,这就是有序性问题。

表明你知道啥是指令重排,也知道他的实现原理。

我们也知道,最好的解决有序性问题的办法,就是禁止处理器优化和指令重排,就像 volatile 中使用内存屏障一样。

重点!解释下什么是 as-if-serial 语义,因为这是这道题的第一个关键词,答上来就对了一半了。

但是,虽然很多硬件都会为了优化做一些重排,但是在 Java 中,不管怎么排序,都不能影响单线程程序的执行结果。这就是 as-if-serial 语义,所有硬件优化的前提都是必须遵守 as-if-serial 语义。as-if-serial 语义把单线程程序保护了起来,遵守 as-if-serial 语义的编译器,runtime 和处理器共同为编写单线程程序的程序员创建了一个幻觉:单线程程序是按程序的顺序来执行的。as-if-serial 语义使单线程程序员无需担心重排序会干扰他们,也无需担心内存可见性问题。

介绍 synchronized 的原理,这是本题的第二个关键点,到这里基本就可以拿满分了。

再说下 synchronized,他是 Java 提供的锁,可以通过他对 Java 中的对象加锁,并且他是一种排他的、可重入的锁。所以,当某个线程执行到一段被 synchronized 修饰的代码之前,会先进行加锁,执行完之后再进行解锁。在加锁之后,解锁之前,其他线程是无法再次获得锁的,只有这条加锁线程可以重复获得该锁。synchronized 通过排他锁的方式就保证了同一时间内,被 synchronized 修饰的代码是单线程执行的。所以呢,这就满足了 as-if-serial 语义的一个关键前提,那就是单线程,因为有 as-if-serial 语义保证,单线程的有序性就天然存在了。

CountDownLatch

概念

让一线程阻塞直到另一些线程完成一系列操作才被唤醒。

CountDownLatch主要有两个方法(await(),countDown())。

当一个或多个线程调用await()时,调用线程会被阻塞。其它线程调用countDown()会将计数器减1(调用countDown方法的线程不会阻塞),当计数器的值变为零时,因调用await方法被阻塞的线程会被唤醒,继续执行。

假设一个自习室里有7个人,其中有一个是班长,班长的主要职责就是在其它6个同学走了后,关灯,锁教室门,然后走人,因此班长是需要最后一个走的,那么就可以使用CountDownLatch控制班长这个线程是最后一个执行,而其它线程是随机执行的。

CountDownLatch代码演示

import java.util.concurrent.CountDownLatch;

public class CountDownLatchDemo {
    public static void main(String[] args) throws InterruptedException {
    //我们一共创建 6 个线程,然后CountDownLatch计数器的值也设置成 6
    CountDownLatch countDownLatch = new CountDownLatch(6);
    for (int i = 0; i <= 6; i++) {
        new Thread(() -> {
            System.out.println(Thread.currentThread().getName() + "\t 上完自习,离开教室");
            //然后每次学生线程执行完,就让计数器的值减1
            countDownLatch.countDown();
        }, String.valueOf(i)).start();
    }
    //最后我们需要通过 CountDownLatch 的 await 方法来控制班长主线程的执行,这里countDownLatch.await()可以想成是一道墙,只有当计数器的值为 0 的时候,墙才会消失,主线程才能继续往下执行
    countDownLatch.await();
    System.out.println(Thread.currentThread().getName() + "\t 班长最后关门");
	}
}

输出结果:

0	 上完自习,离开教室
6	 上完自习,离开教室
4	 上完自习,离开教室
5	 上完自习,离开教室
3	 上完自习,离开教室
1	 上完自习,离开教室
2	 上完自习,离开教室
main	 班长最后关门

温习枚举

这里主要用到了枚举的自定义属性和其构造方法,枚举类中的每一个元素都可以给其一个构造方法,这个构造方法用于给这个枚举元素赋上一些属性。我们可以通过枚举的元素获取这些属性,枚举类中的元素就相当于枚举类的一个个实例。

枚举 + CountDownLatch

程序演示秦国统一六国

import java.util.Objects;

public enum CountryEnum {
	ONE(1, "齐"), TWO(2, "楚"), THREE(3, "燕"), FOUR(4, "赵"), FIVE(5, "魏"), SIX(6, "韩");
	private Integer retcode;
	private String retMessage;
    
	CountryEnum(Integer retcode, String retMessage) {
		this.retcode = retcode;
		this.retMessage = retMessage;
	}

	public static CountryEnum forEach_countryEnum(int index) {
		CountryEnum[] myArray = CountryEnum.values();
		for(CountryEnum ce : myArray) {
			if(Objects.equals(index, ce.getRetcode())) {
				return ce;
			}
		}
		return null;
	}

	public Integer getRetcode() {
		return retcode;
	}

	public void setRetcode(Integer retcode) {
		this.retcode = retcode;
	}

	public String getRetMessage() {
		return retMessage;
	}

	public void setRetMessage(String retMessage) {
		this.retMessage = retMessage;
	}
}
import java.util.concurrent.CountDownLatch;

public class UnifySixCountriesDemo {
	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();
       		}, CountryEnum.forEach_countryEnum(i).getRetMessage()).start();
    	}
    	countDownLatch.await();
    	System.out.println(Thread.currentThread().getName() + " 秦国统一中原。");
	}
}

输出结果:

齐国被灭了!
燕国被灭了!
楚国被灭了!
魏国被灭了!
韩国被灭了!
赵国被灭了!
main 秦国统一中原

CyclicBarrier

概念

CyclicBarrier的字面意思就是可循环(Cyclic)使用的屏障(Barrier)。它要求做的事情是,让一组线程到达一个屏障(也可以叫同步点)时被阻塞,直到最后一个线程到达屏障时,屏障才会开门,所有被屏障拦截的线程才会继续干活,线程进入屏障是通过CyclicBarrier的await方法。它和CountDownLatch相反,CountDownLatch是做减法,它是做加法,开始是0,加到某个值的时候才会开始执行。比如需要集齐七颗龙珠,才可以召唤出神龙。

CyclicBarrier与CountDownLatch的区别:CyclicBarrier可重复多次,而CountDownLatch只能是一次。

CyclicBarrier代码演示

import java.util.concurrent.BrokenBarrierException;
import java.util.concurrent.CyclicBarrier;

public class SummonTheDragonDemo {
    public static void main(String[] args) {
        /**
         * 定义一个循环屏障,参数1:需要累加的值,参数2是满足条件后需要执行的方法
         */
        CyclicBarrier cyclicBarrier = new CyclicBarrier(7, () -> {
            System.out.println("召唤神龙");
        });
        //同时进行七个线程进行龙珠收集,当一个线程收集到了的时候,我们需要让他执行await方法,等待其他的线程进行收集龙珠,也就是到7个线程全部执行完毕后,才继续执行原来定义好的方法
        for (int i = 1; i <= 7; i++) {
            final Integer tempInt = i;
            new Thread(() -> {
                System.out.println(Thread.currentThread().getName() + "\t 收集到第" + tempInt + "颗龙珠");
                try {
                    // 先到的被阻塞,等全部线程完成后,才能执行方法
                    cyclicBarrier.await();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                } catch (BrokenBarrierException e) {
                    e.printStackTrace();
                }
            }, String.valueOf(i)).start();
        }
    }
}

输出结果:

2	 收集到 第2颗龙珠
6	 收集到 第6颗龙珠
1	 收集到 第1颗龙珠
7	 收集到 第7颗龙珠
5	 收集到 第5颗龙珠
4	 收集到 第4颗龙珠
3	 收集到 第3颗龙珠
召唤神龙

Semaphore

Semaphore概念

Semaphore(信号量)主要用于两个目的,一个是用于多个共享资源的互斥使用,另一个用于并发线程数的控制。

正常的锁(concurrency.locks或synchronized锁)在任何时刻都只允许一个任务访问一项资源,而 Semaphore允许n个任务同时访问这个资源。

Semaphore代码演示

模拟一个抢车位的场景,假设一共有6个车,3个停车位

import java.util.concurrent.Semaphore;
import java.util.concurrent.TimeUnit;

public class SemaphoreDemo {
    public static void main(String[] args) {
        /**
         * 初始化一个信号量为3,默认是false非公平锁, 模拟3个停车位
         */
        Semaphore semaphore = new Semaphore(3, false);
        // 模拟6部车
        for (int i = 0; i < 6; i++) {
            new Thread(() -> {
                try {
                    // 代表一辆车,已经占用了该车位
                    semaphore.acquire(); // 抢占
                    System.out.println(Thread.currentThread().getName() + "\t 抢到车位");
                        // 每个车停3秒
                    try {
                        TimeUnit.SECONDS.sleep(3);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                    System.out.println(Thread.currentThread().getName() + "\t 离开车位");
                } catch (InterruptedException e) {
                    e.printStackTrace();
                } finally {
                    // 释放停车位
                    semaphore.release();
                }
            }, String.valueOf(i)).start();
        }
    }
}

输出结果:

1	 抢到车位
2	 抢到车位
0	 抢到车位
0	 离开车位
2	 离开车位
1	 离开车位
5	 抢到车位
4	 抢到车位
3	 抢到车位
5	 离开车位
4	 离开车位
3	 离开车位

阻塞队列

概念

阻塞队列,顾名思义,首先它是一个队列,而一个阻塞队列在数据结构中所起的作用大致如下图所示:

image-20221014152158739

线程1往阻塞队列中添加元素,而线程2从阻塞队列中移除元素。

  • 当阻塞队列是空时,从队列中获取元素的操作将会被阻塞。

  • 当阻塞队列是满时,往队列里添加元素的操作将会被阻塞。

试图从空的阻塞队列中获取元素的线程将会被阻塞,直到其他的线程往空的队列插入新的元素。同样试图往已满的阻塞队列中添加新元素的线程同样也会被阻塞,直到其他的线程从列中移除一个或者多个元素或者完全清空队列后使队列重新变得空闲起来并后续新增

为什么用?有什么好处?

在多线程领域:所谓阻塞,在某些情况下挂起线程(即阻塞),一旦条件满足,被挂起的线程又会自动被唤醒。比如去海底捞吃饭,大厅满了,需要进候厅等待,因为这些等待的客户能够对商家带来利润,因此商家非常欢迎他们阻塞
BlockingQueue的好处是我们不需要关心什么时候需要阻塞线程,什么时候需要唤醒线程,因为这一切BlockingQueue都给你一手包办了。在Concurrent包发布以前,在多线程环境下,我们每个程序员都必须去自己控制这些细节,尤其还要兼顾效率和线程安全,而这会给我们的程序带来不小的复杂度。

BlockingQueue的架构分析

ArrayBlockingQueue: 由数组结构组成的有界阻塞队列。
LinkedBlockingQueue: 由链表结构组成的有界(但大小默认值为Integer.MAX_VALUE)阻塞队列。
PriorityBlockingQueue:支持优先级排序的无界阻塞队列。
DelayQueue:使用优先级队列实现妁延迟无界阻塞队列。
SynchronousQueue: 不存储元素的阻塞队列。
LinkedTransferQueue:由链表结构绒成的无界阻塞队列。
LinkedBlockingDeque:由链表结构组成的双向阻塞队列。

BlockingQueue的核心方法

方法类型抛出异常特殊值阻塞超时
插入add(e)offer(e)put(e)offer(e,time,unit)
移除remove()poll()take()poll(time,unit)
检查element()peek()不可用不可用
性质说明
抛出异常当阻塞队列满时:在往队列中add插入元素会抛出 IIIegalStateException:
Queue full 当阻塞队列空时:再往队列中remove移除元素,会抛出NoSuchException
特殊性插入方法:成功true,失败false 移除方法:成功则返回出队列元素,队列没有就返回空
一直阻塞当阻塞队列满时,生产者继续往队列里put元素,队列会一直阻塞生产线程直到put数据or响应中断退出。
当阻塞队列空时,消费者线程试图从队列里take元素,队列会一直阻塞消费者线程直到队列可用。
超时退出当阻塞队列满时,队列会阻塞生产者线程一定时间,超过限时后生产者线程会退出
抛出异常组API代码演示
import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.BlockingQueue;

public class BlockingQueueExceptionDemo {
	public static void main(String[] args) {
		BlockingQueue<String> blockingQueue = new ArrayBlockingQueue<>(3);
        
		System.out.println(blockingQueue.add("a"));
		System.out.println(blockingQueue.add("b"));
		System.out.println(blockingQueue.add("c"));
		try {
			//抛出 java.lang.IllegalStateException: Queue full
			System.out.println(blockingQueue.add("XXX"));
		} catch (Exception e) {
			System.err.println(e);
		}
		System.out.println(blockingQueue.element());
		System.out.println(blockingQueue.remove());
		System.out.println(blockingQueue.remove());
		System.out.println(blockingQueue.remove());
		try {
			//抛出 java.util.NoSuchElementException
			System.out.println(blockingQueue.remove());			
		} catch (Exception e) {
			System.err.println(e);
		}
		try {
			//element()相当于peek(),但element()会抛NoSuchElementException
			System.out.println(blockingQueue.element());
		} catch (Exception e) {
			System.err.println(e);
		}
	}
}

输出结果:

true
true
true
java.lang.IllegalStateException: Queue full
a
a
b
c
java.util.NoSuchElementException
java.util.NoSuchElementException
返回布尔值组API代码演示
import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.BlockingQueue;

public class BlockingQueueBooleanDemo {
	public static void main(String[] args) {
		BlockingQueue<String> blockingQueue = new ArrayBlockingQueue<>(3);
        
		System.out.println(blockingQueue.offer("a"));
		System.out.println(blockingQueue.offer("b"));
		System.out.println(blockingQueue.offer("c"));
		System.out.println(blockingQueue.offer("d"));
        
		System.out.println(blockingQueue.poll());
		System.out.println(blockingQueue.poll());
		System.out.println(blockingQueue.poll());
		System.out.println(blockingQueue.poll());
		}
}

输出结果:

true
true
true
false
a
b
c
null
阻塞组API代码演示

下面的代码会首先向队列中添加三个元素然后添加就会被阻塞,直到队列中元素被取出。然后取元素时如果队列为空那么也会被阻塞,直到队列中有元素添加进去。

import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.TimeUnit;

public class BlockingQueueBlockedDemo {
	public static void main(String[] args) throws InterruptedException {
		BlockingQueue<String> blockingQueue = new ArrayBlockingQueue<>(3);

		new Thread(()->{
			try {
				blockingQueue.put("a");
				blockingQueue.put("b");
				blockingQueue.put("c");
				blockingQueue.put("c");//将会阻塞,直到主线程take()
				System.out.println("it was blocked.");
			} catch (InterruptedException e) {
				e.printStackTrace();
			}
		}).start();
		TimeUnit.SECONDS.sleep(2);
		try {
			blockingQueue.take();
			blockingQueue.take();
			blockingQueue.take();
			System.out.println("Blocking...");
			blockingQueue.take();//将会阻塞
		} catch (InterruptedException e) {
			e.printStackTrace();
		}
	}
}

执行结果

Blocking...
it was blocked.
阻塞超时放弃组API代码演示
import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.TimeUnit;

public class BlockingQueueTimeoutDemo {

	public static void main(String[] args) throws InterruptedException {
		BlockingQueue<String> blockingQueue = new ArrayBlockingQueue<>(3);
		
		System.out.println("Offer.");
		System.out.println(blockingQueue.offer("a", 2L, TimeUnit.SECONDS));
		System.out.println(blockingQueue.offer("b", 2L, TimeUnit.SECONDS));
		System.out.println(blockingQueue.offer("c", 2L, TimeUnit.SECONDS));
		System.out.println(blockingQueue.offer("d", 2L, TimeUnit.SECONDS));
		
		System.out.println("Poll.");
		System.out.println(blockingQueue.poll(2L, TimeUnit.SECONDS));
		System.out.println(blockingQueue.poll(2L, TimeUnit.SECONDS));
		System.out.println(blockingQueue.poll(2L, TimeUnit.SECONDS));
		System.out.println(blockingQueue.poll(2L, TimeUnit.SECONDS));
	}
}

输出结果:

Offer.
true
true
true
等待2s
false
Poll.
a
b
c
等待2s
null

阻塞队列之同步SynchronousQueue队列

SynchronousQueue没有容量。与其他BlockingQueue不同,SynchronousQueue是一个不存储元素的BlockingQueue。可以理解为它本身就是一个元素,但是其没有存储其他元素的能力

每一个put操作必须要等待一个take操作,否则不能继续添加元素,反之亦然。

import java.util.concurrent.BlockingQueue;
import java.util.concurrent.SynchronousQueue;
import java.util.concurrent.TimeUnit;

public class SynchronousQueueDemo {
	public static void main(String[] args) {
		BlockingQueue<String> blockingQueue = new SynchronousQueue<>();

		new Thread(() -> {
		    try {       
		        System.out.println(Thread.currentThread().getName() + "\t put A ");
		        blockingQueue.put("A");
		        System.out.println(Thread.currentThread().getName() + "\t put B ");
		        blockingQueue.put("B");        
		        System.out.println(Thread.currentThread().getName() + "\t put C ");
		        blockingQueue.put("C");        
		    } catch (InterruptedException e) {
		        e.printStackTrace();
		    }
		}, "t1").start();
		
		new Thread(() -> {
			try {
				try {
					TimeUnit.SECONDS.sleep(5);
				} catch (InterruptedException e) {
					e.printStackTrace();
				}
				blockingQueue.take();
				System.out.println(Thread.currentThread().getName() + "\t take A ");
				try {
					TimeUnit.SECONDS.sleep(5);
				} catch (InterruptedException e) {
					e.printStackTrace();
				}
				blockingQueue.take();
				System.out.println(Thread.currentThread().getName() + "\t take B ");
				try {
					TimeUnit.SECONDS.sleep(5);
				} catch (InterruptedException e) {
					e.printStackTrace();
				}
				blockingQueue.take();
				System.out.println(Thread.currentThread().getName() + "\t take C ");
			} catch (InterruptedException e) {
				e.printStackTrace();
			}
		}, "t2").start();
	}
}

最后结果输出为:

t1put A
5秒后...
t2take A
t1put B
5秒后...
t2takeB 
t1put C
5秒后...
t2take C 

我们从最后的运行结果可以看出,每次 t1 线程向队列中添加阻塞队列添加元素后,t1输入线程就会等待 t2 消费线程,t2消费后,t2处于挂起状态,等待 t1再存入,从而周而复始,形成一存一取的状态。

阻塞队列的用处

  • 生产者消费者模式
    • 传统版(synchronized, wait, notify)
    • 阻塞队列版(lock, await, signal)
  • 线程池
  • 消息中间件
生产者消费者模式
线程通信之生产者消费者传统版

下面复习一下最简单的生产者消费者模式:一个初始值为 0 的变量,两个线程对其交替操作,一个加 1,一个减 1,来 5 轮。
关于多线程的操作,我们需要记住下面几句

  • 线程操作资源类
  • 判断干活和通知
  • 防止虚假唤醒机制
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
/**
* 资源类
*/
class ShareData {
    private int number = 0;
    private Lock lock = new ReentrantLock();
    private Condition condition = lock.newCondition();
    
    public void increment() throws Exception{
        // 同步代码块,加锁
        lock.lock();
        try {
            // 判断
            while(number != 0) {
                // 等待不能生产
                condition.await();
            }
            // 干活
            number++;
            System.out.println(Thread.currentThread().getName() + "\t " + number);
            // 通知 唤醒
            condition.signalAll();
        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            lock.unlock();
        }
    }
    
    public void decrement() throws Exception{
        // 同步代码块,加锁
        lock.lock();
        try {
            // 判断
            while(number == 0) {
                // 等待不能消费
                condition.await();
            }
            // 干活
            number--;
            System.out.println(Thread.currentThread().getName() + "\t " + number);
            // 通知 唤醒
            condition.signalAll();
        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            lock.unlock();
        }
    }
}

public class TraditionalProducerConsumerDemo {
	public static void main(String[] args) {
	    ShareData shareData = new ShareData();
        
	    // t1线程,生产
	    new Thread(() -> {
	        for (int i = 0; i < 5; i++) {
	            try {
	                shareData.increment();
	            } catch (Exception e) {
	                e.printStackTrace();
	            }
	        }
	    }, "t1").start();
	    // t2线程,消费
	    new Thread(() -> {
	        for (int i = 0; i < 5; i++) {
	            try {
	                shareData.decrement();
	            } catch (Exception e) {
	                e.printStackTrace();
	            }
	        }
	    }, "t2").start();    
	}
}	

输出结果:

t1	 1
t2	 0
......*4

注意,increment()和decrement()内的
// 判断
while(number != 0) {
// 等待不能生产
condition.await();
}
不能用
// 判断
if(number != 0) {
// 等待不能生产
condition.await();
}
否则会出现虚假唤醒,出现异常状况。

线程通信之生产者消费者阻塞队列版

在 concurrent 包发布以前,在多线程环境下,我们每个程序员都必须自己去控制这些细节,尤其还要兼顾效率和线程安全,则这会给我们的程序带来不小的时间复杂度。
现在我们使用新版的阻塞队列版生产者和消费者,使用:volatile、CAS、atomicInteger、BlockQueue、线程交互、原子引用。

import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;

class MyResource {
    // 默认开启,进行生产消费
    // 这里用到了volatile是为了保持数据的可见性,也就是当TLAG修改时,要马上通知其它线程进行修改
    private volatile boolean FLAG = true;
    // 使用原子包装类,而不用number++
    private AtomicInteger atomicInteger = new AtomicInteger();
    // 这里不能为了满足条件,而实例化一个具体的SynchronousBlockingQueue
    BlockingQueue<String> blockingQueue = null;
    
    // 而应该采用依赖注入里面的,构造注入方法传入
    public MyResource(BlockingQueue<String> blockingQueue) {
        this.blockingQueue = blockingQueue;
        // 查询出传入的class是什么
        System.out.println(blockingQueue.getClass().getName());
    }

    public void myProducer() throws Exception{
        String data = null;
        boolean retValue;
        // 多线程环境的判断,一定要使用while进行,防止出现虚假唤醒
        // 当FLAG为true的时候,开始生产
        while(FLAG) {
            data = atomicInteger.incrementAndGet() + "";
            // 2秒存入1个data
            retValue = blockingQueue.offer(data, 2L, TimeUnit.SECONDS);
            if(retValue) {
                System.out.println(Thread.currentThread().getName() + "\t 插入队列:" + data  + "成功" );
            } else {
                System.out.println(Thread.currentThread().getName() + "\t 插入队列:" + data  + "失败" );
            }
            try {
                TimeUnit.SECONDS.sleep(1);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
        System.out.println(Thread.currentThread().getName() + "\t 停止生产,表示FLAG=false,生产结束");
    }

    public void myConsumer() throws Exception{
        String retValue;
        // 多线程环境的判断,一定要使用while进行,防止出现虚假唤醒
        // 当FLAG为true的时候,开始生产
        while(FLAG) {
            // 设置超时时间为2s
            retValue = blockingQueue.poll(2L, TimeUnit.SECONDS);
            if(retValue != null && retValue != "") {
                System.out.println(Thread.currentThread().getName() + "\t 消费队列:" + retValue  + "成功" );
            } else {
                FLAG = false;
                System.out.println(Thread.currentThread().getName() + "\t 消费失败,队列中已为空,退出" );
                // 退出消费队列
                return;
            }
        }
    }
    /**
     * 停止生产的判断
     */
    public void stop() {
        this.FLAG = false;
    }
}
public class ProducerConsumerWithBlockingQueueDemo {
    public static void main(String[] args) {
        // 传入具体的实现类, ArrayBlockingQueue
        MyResource myResource = new MyResource(new ArrayBlockingQueue<String>(10));

        new Thread(() -> {
            System.out.println(Thread.currentThread().getName() + "\t 生产线程启动");
    
            try {
                myResource.myProducer();
                System.out.println("\n");
    
            } catch (Exception e) {
                e.printStackTrace();
            }
        }, "producer").start();

        new Thread(() -> {
            System.out.println(Thread.currentThread().getName() + "\t 消费线程启动\n");
    
            try {
                myResource.myConsumer();
            } catch (Exception e) {
                e.printStackTrace();
            }
        }, "consumer").start();
        // 5秒后,停止生产和消费
        try {
            TimeUnit.SECONDS.sleep(5);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println("\n\n5秒中后,生产和消费线程停止,线程结束");
        myResource.stop();
    }
}

输出结果:

java.util.concurrent.ArrayBlockingQueue
producer	 生产线程启动
consumer	 消费线程启动

producer	 插入队列:1成功
consumer	 消费队列:1成功
producer	 插入队列:2成功
consumer	 消费队列:2成功
producer	 插入队列:3成功
consumer	 消费队列:3成功
producer	 插入队列:4成功
consumer	 消费队列:4成功
producer	 插入队列:5成功
consumer	 消费队列:5成功


5秒中后,生产和消费线程停止,线程结束
producer	 停止生产,表示FLAG=false,生产结束
consumer	 消费失败,队列中已为空,退出

整个过程不需要我们手动控制生产和消费,完全由阻塞队列进行判断处理,我们只需要控制总开关即可。

Synchronized和Lock的区别

早期的时候我们对线程的主要操作为:synchronized wati notify
然后后面出现了替代方案:lock await singal。具体如下图

image-20221014195733991

synchronized 和 lock 有什么区别?用新的 lock 有什么好处?举例说明

  1. synchronized属于JVM层面,属于java的关键字

    • monitorenter(底层是通过monitor对象来完成,其实wait/notify等方法也依赖于monitor对象,所以只能在同步块或者方法中才能调用 wait/ notify等方法)

    • Lock是具体类(java.util.concurrent.locks.Lock)是api层面的锁

  2. 使用方法:

    • synchronized:不需要用户去手动释放锁,当synchronized代码执行后,系统会自动让线程释放对锁的占用。
    • ReentrantLock:则需要用户去手动释放锁,若没有主动释放锁,就有可能出现死锁的现象,需要lock() 和 unlock() 配置try catch语句来完成
  3. 等待是否中断

    • synchronized:不可中断,除非抛出异常或者正常运行完成。
    • ReentrantLock:可中断,可以设置超时方法
      • 设置超时方法,trylock(long timeout, TimeUnit unit)
      • lockInterrupible() 放代码块中,调用interrupt() 方法可以中断
  4. 加锁是否公平

    • synchronized:非公平锁
    • ReentrantLock:默认非公平锁,构造函数可以传递boolean值,true为公平锁,false为非公平锁
  5. 锁绑定多个条件Condition

    • synchronized:没有,要么随机,要么全部唤醒
    • ReentrantLock:用来实现分组唤醒需要唤醒的线程,可以精确唤醒,而不是像synchronized那样,要么随机,要么全部唤醒

锁绑定多个条件Condition实现场景

可以使用Lock锁的实例来创建条件,然后把线程的暂停和唤醒都可以由这个条件来进行操作。

多线程之间按顺序调用,实现 A-> B -> C 三个线程启动,要求如下:
A打印5次,B打印10次,C打印15次
紧接着
A打印5次,B打印10次,C打印15次

一共来10轮

import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

class ShareResource {
    // A 1   B 2   c 3
    private int number = 1;
    // 创建一个重入锁
    private Lock lock = new ReentrantLock();

    // 这三个相当于备用钥匙
    private Condition condition1 = lock.newCondition();
    private Condition condition2 = lock.newCondition();
    private Condition condition3 = lock.newCondition();
    
    public void print5() {
        lock.lock();
        try {
            // 判断
            while(number != 1) {
                // 不等于1,需要等待
                condition1.await();
            }
            // 干活
            for (int i = 0; i < 5; i++) {
                System.out.println(Thread.currentThread().getName() + "\t " + number + "\t" + i);
            }
            // 唤醒 (干完活后,需要通知B线程执行)
            number = 2;
            // 通知2号去干活了
            condition2.signal();
        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            lock.unlock();
        }
    }
    public void print10() {
        lock.lock();
        try {
            // 判断
            while(number != 2) {
                // 不等于1,需要等待
                condition2.await();
            }
                // 干活
            for (int i = 0; i < 10; i++) {
                System.out.println(Thread.currentThread().getName() + "\t " + number + "\t" + i);
            }
            // 唤醒 (干完活后,需要通知C线程执行)
            number = 3;
            // 通知2号去干活了
            condition3.signal();
        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            lock.unlock();
        }
    }
    public void print15() {
        lock.lock();
        try {
            // 判断
            while(number != 3) {
                // 不等于1,需要等待
                condition3.await();
            }
            // 干活
            for (int i = 0; i < 15; i++) {
                System.out.println(Thread.currentThread().getName() + "\t " + number + "\t" + i);
            }
            // 唤醒 (干完活后,需要通知C线程执行)
            number = 1;
            // 通知1号去干活了
            condition1.signal();
        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            lock.unlock();
        }
    }
}

public class SynchronizedAndReentrantLockDemo {
    public static void main(String[] args) {
        ShareResource shareResource = new ShareResource();
        int num = 10;
        new Thread(() -> {
            for (int i = 0; i < num; i++) {
                    shareResource.print5();
            }
        }, "A").start();
        new Thread(() -> {
            for (int i = 0; i < num; i++) {
                shareResource.print10();
            }
        }, "B").start();
        new Thread(() -> {
            for (int i = 0; i < num; i++) {
                shareResource.print15();
            }
        }, "C").start();
    }

}

输出结果:

A	 1	0
A	 1	1
A	 1	2
A	 1	3
A	 1	4
B	 2	0
B	 2	1
B	 2	2
B	 2	3
B	 2	4
B	 2	5
B	 2	6
B	 2	7
B	 2	8
B	 2	9
C	 3	0
C	 3	1
C	 3	2
C	 3	3
C	 3	4
C	 3	5
C	 3	6
C	 3	7
C	 3	8
C	 3	9
C	 3	10
C	 3	11
C	 3	12
C	 3	13
C	 3	14
......*9

线程池

前言

获取多线程的方法,我们目前最常用的只有前两种,下面首先介绍一下实现Callable接口如何创建线程,然后介绍如何从线程池中获取多线程。

  • 实例化Thread 类

  • 实现 Runnable 接口

  • 实现 Callable 接口

  • 使用线程池获取

Callable接口

Callable接口是一种可以让线程执行完成后返回结果的接口,它的主要作用是把某个极其耗费时间的任务分配一个新的线程并发执行,而不是让其在比如main方法中同步执行,因为这会导致其后面的任务堵塞在main线程中。其使用方法和Runnable接口有一点像,下面我们先回顾一下Runnable接口。

/**
 * 实现 Runnable 接口
 */
class MyThread implements Runnable {
    @Override
    public void run() {
    }
}

我们知道,实现 Runnable 接口的时候,需要重写 run 方法,也就是线程在启动的时候,会自动调用的方法。同理,我们实现 Callable 接口,也需要实现 call 方法,但是这个时候我们还需要有返回值,这个 Callable 接口的应用场景一般用在于批处理业务,比如转账的时候,需要给一会返回结果的状态码回来,代表本次操作成功还是失败

/**
 * Callable 有返回值
 * 批量处理的时候,需要带返回值的接口(例如支付失败的时候,需要返回错误状态)
 */
class MyThread2 implements Callable<Integer> {
  @Override
  public Integer call() throws Exception {
  	System.out.println("come in Callable");
  	return 1024;
  }
}

但是如果我们查看Thread类的构造方法的话就会方法其并没有一个构造方法可以传入Callable接口,而是只能传入Runnable接口。所以最后我们需要用到的是 FutureTask 类,他实现了 Runnable 接口,并且还需要传递一个实现 Callable 接口的类作为构造函数。通过FutureTask类我们就可以把Callable接口与Thread类连接起来(Runnable接口更像是一个中间联络人)。

// FutureTask:实现了 Runnable 接口,构造函数又需要传入 Callable 接口
// 这里通过了 FutureTask连接了Callable 接口
FutureTask<Integer> futureTask = new FutureTask<>(new MyThread2());
然后在用 Thread 进行实例化,传入实现 Runnabnle 接口的 FutureTask 的类
Thread t1 = new Thread(futureTask, "aaa");
t1.start();
最后通过 futureTask.get() 获取到返回值
// 输出 FutureTask 的返回值
System.out.println("result FutureTask " + futureTask.get());

这就相当于原来我们的方式是 main 方法一直执行直到执行完毕,后面在引入 Callable 后,对于执行比较久的线程,可以单独新开一个线程进行执行,最后在进行汇总输出。
最后需要注意的是要求获得 Callable 线程的计算结果,如果没有计算完成就要去强求,会导致阻塞,直到计算完成。也就是说 futureTask.get() 需要放在最后执行,这样不会导致主线程阻塞。也可以使用类似于自旋锁的方式来判断是否运行完毕。

// 判断 futureTask 是否计算完成
while(!futureTask.isDone()) {
}

注意多个线程执行 一个 FutureTask 的时候,只会计算一次

FutureTask<Integer> futureTask = new FutureTask<>(new MyThread2());
// 开启两个线程计算 futureTask
new Thread(futureTask, "AAA").start();
new Thread(futureTask, "BBB").start();

如果我们要两个线程同时计算任务的话,那么需要这样写,需要定义两个futureTask

FutureTask<Integer> futureTask = new FutureTask<>(new MyThread2());
FutureTask<Integer> futureTask2 = new FutureTask<>(new MyThread2());
// 开启两个线程计算 futureTask
new Thread(futureTask, "AAA").start();
new Thread(futureTask2, "BBB").start();
Callable完整代码演示
import java.util.concurrent.Callable;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.FutureTask;
import java.util.concurrent.TimeUnit;

class MyThread implements Callable<Integer> {
    @Override
    public Integer call() throws Exception {
        System.out.println(Thread.currentThread().getName() + " come in Callable");
        TimeUnit.SECONDS.sleep(2);
        return 1024;
    }
}

public class CallableDemo {
	public static void main(String[] args) throws InterruptedException, ExecutionException {
		FutureTask<Integer> futureTask = new FutureTask<>(new MyThread());
        new Thread(futureTask, "A").start();
        Date date = new Date();
        TimeUnit.SECONDS.sleep(2);
        // 输出FutureTask的返回值
        System.out.println("result FutureTask " + futureTask.get());
        Date date1 = new Date();
        System.out.println(date1.getTime()-date.getTime());//2015
	}
}

为什么用线程池

线程池做的主要工作就是控制运行的线程的数量。线程池处理过程中,将任务放入到队列中,然后线程创建后,启动这些任务,如果线程数量超过了最大数量。那么队列中的任务排队等候,等其它线程执行完毕,再从队列中取出这些等待着的任务来执行。
**它的主要特点为:**线程复用、控制最大并发数、管理线程
线程池中的任务是放入到阻塞队列中的。

当前计算机已经进入了多核时代,也就是说我们的计算机可以有多个CPU同时执行任务,这种CPU多核处理的好处是:省略上下文的切换开销。因为一个CPU一次只能运行一个线程,那么多CPU就可以同一时间执行多个任务,而不用像原来单核时CPU每执行一个线程的任务都需要切换线程。

线程池使用及优势

线程池做的工作主要是控制运行的线程的数量,处理过程中将任务放入队列,然后在线程创建后启动这些任务,如果线程数量超过了最大数量超出数量的线程排队等候,等其它线程执行完毕,再从队列中取出任务来执行。

线程池的主要特点为:线程复用,控制最大并发数,管理线程。

我么可以把线程池类比于Spring的IOC容器。原来我们实例化对象的时候,是使用 new 关键字进行创建,到了 Spring 后,我们
学了 IOC 依赖注入,发现 Spring 帮我们将对象已经加载到了 Spring 容器中,只需要通过@Autowrite 注解,就能够自动注入,从而使用实例化对象。线程池也是这样,它里面保存着的是线程对象,如果需要使用线程直接从里面取就可以了而不用自己创建新的线程。

优点:

降低资源消耗。通过重复利用己创建的线程降低线程创建和销毁造成的消耗。
提高响应速度。当任务到达时,任务可以不需要的等到线程创建就能立即执行。
提高线程的可管理性。线程是稀缺资源,如果无限制的创建,不仅会消耗系统资源,还会降低系统的稳定性,使用线程池可以进行统一的分配,调优和监控。

线程池3个常用方式

Java中的线程池是通过Executor框架实现的,该框架中用到了Executor,Executors(s代表工具类),ExecutorService,ThreadPoolExecutor这几个类。

image-20221015122538326

我们这里只学习最重要的三种获取线程池的方法。

  • Executors.newFixedThreadPool(int i) :创建一个拥有i个线程的线程池
    • 执行长期的任务,性能好很多
    • 创建一个定长线程池,可控制线程数最大并发数,超出的线程会在队列中等待
  • Executors.newSingleThreadExecutor():创建一个只有1个线程的单线程池
    • 一个任务一个任务执行的场景
    • 创建一个单线程化的线程池,它只会用唯一的工作线程来执行任务,保证所有任务按照指定顺序执行
  • Executors.newCacheThreadPool(); 创建一个可扩容的线程池即一个拥有N个线程的线程池,其可根据线程调度创建合适的线程数。
    • 执行很多短期异步的小程序或者负载教轻的服务器
    • 创建一个可缓存线程池,如果线程长度超过处理需要,可灵活回收空闲线程,如无可回收,则新建新线程

下面介绍它们的具体使用,首先我们需要使用 Executors 工具类,进行创建线程池,这里创建了一个拥有 5 个线程的线程池

import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

public class ThreadPoolDemo {
    public static void main(String[] args) {
    	// 一池5个处理线程(用池化技术,一定要记得关闭)
//    	ExecutorService threadPool = Executors.newFixedThreadPool(5);
    	// 创建一个只有一个线程的线程池
//    	ExecutorService threadPool = Executors.newSingleThreadExecutor();
    	// 创建一个拥有N个线程的线程池,根据调度创建合适的线程
    	ExecutorService threadPool = Executors.newCachedThreadPool();
        // 模拟10个用户来办理业务,每个用户就是一个来自外部请求线程
        try {
            // 循环十次,模拟业务办理。
            for (int i = 0; i < 10; i++) {
                final int tempInt = i;
                threadPool.execute(() -> {
                    System.out.println(Thread.currentThread().getName() + "\t 给用户:" + tempInt + " 办理业务");
                });
            }
        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            threadPool.shutdown();
        }
    }
}

输出结果:

pool-1-thread-1	 给用户:0 办理业务
pool-1-thread-6	 给用户:5 办理业务
pool-1-thread-5	 给用户:4 办理业务
pool-1-thread-2	 给用户:1 办理业务
pool-1-thread-4	 给用户:3 办理业务
pool-1-thread-3	 给用户:2 办理业务
pool-1-thread-10 给用户:9 办理业务
pool-1-thread-9	 给用户:8 办理业务
pool-1-thread-8	 给用户:7 办理业务
pool-1-thread-7	 给用户:6 办理业务
底层实现

我们通过查看Executors.newSingleThreadExecutor 和Executors.newFixedThreadPool的源码时能够发现底层都是使用了 ThreadPoolExecutor来进行封装。我们还可以看到线程池的内部使用到了 LinkedBlockingQueue 链表阻塞队列。同时再查看Executors.newCacheThreadPool的源码会发现其底层用的是SynchronousBlockingQueue 阻塞队列
下面展示一下三个比较重点的创建线程的方法的源码

public static ExecutorService newFixedThreadPool(int nThreads) {
    return new ThreadPoolExecutor(nThreads, nThreads,
                                      0L, TimeUnit.MILLISECONDS,
                                      new LinkedBlockingQueue<Runnable>());
    }
public static ExecutorService newSingleThreadExecutor() {
    return new FinalizableDelegatedExecutorService
            (new ThreadPoolExecutor(1, 1,
                                    0L, TimeUnit.MILLISECONDS,
                                    new LinkedBlockingQueue<Runnable>()));
    }
public static ExecutorService newCachedThreadPool() {
    return new ThreadPoolExecutor(0, Integer.MAX_VALUE,
                                      60L, TimeUnit.SECONDS,
                                      new SynchronousQueue<Runnable>());
    }

创建周期性执行任务的线程池

Executors.newScheduledThreadPool(int corePoolSize):线程池支持定时以及周期性执行任务,创建一个 corePoolSize 为传入参数,最大线程数为整形的最大数的线程池。
其底层使用 ScheduledThreadPoolExecutor来实现。这个类为ThreadPoolExecutor的子类

public ScheduledThreadPoolExecutor(int corePoolSize) {
	super(corePoolSize, Integer.MAX_VALUE, 0, NANOSECONDS,
	new DelayedWorkQueue());
}

这个类有以下方法:

/**
* @throws RejectedExecutionException {@inheritDoc}
* @throws NullPointerException {@inheritDoc}
* command:执行的任务 Callable 或 Runnable 接口实现类
* delay:延时执行任务的时间
* unit:延迟时间单位
*/
public ScheduledFuture<?> schedule(Runnable command,long delay,TimeUnit unit)
/**
* @throws IllegalArgumentException {@inheritDoc}
* initialDelay 第一次执行任务延迟时间
* period 连续执行任务之间的周期,从上一个任务开始执行时计算延迟多少开始执行下一个任务,但是还会等上一个任务结束之后。
*/
//上面方法有的文档下面这两个方法都有,为了看着简介进行了删除
public ScheduledFuture<?> scheduleAtFixedRate(Runnable command,long initialDelay,long period,TimeUnit unit)
/**
* @throws IllegalArgumentException {@inheritDoc}
* command:执行的任务 Callable 或 Runnable 接口实现类
* initialDelay 第一次执行任务延迟时间
* delay:连续执行任务之间的周期,从上一个任务全部执行完成时计算延迟多少开始执行下一个任务
*/
public ScheduledFuture<?> scheduleWithFixedDelay(Runnable command,long initialDelay,long delay,TimeUnit unit)

线程池7大参数

我们可以看到之前提到的创建线程池的方法中实际上都是new了一个ThreadPoolExecutor的实例,而且都提供了五个参数,但实际上ThreadPoolExecutor的构造参数是有七个参数的,五个参数的构造方法本质上也是调用了这个七个参数的构造方法,所以下面我们来阅读一下ThreadPoolExecutor这个七参构造方法的源码:

public ThreadPoolExecutor(int corePoolSize,
                          int maximumPoolSize,
                          long keepAliveTime,
                          TimeUnit unit,
                          BlockingQueue<Runnable> workQueue,
                          ThreadFactory threadFactory,
                          RejectedExecutionHandler handler) {
    if (corePoolSize < 0 ||
        maximumPoolSize <= 0 ||
        maximumPoolSize < corePoolSize ||
        keepAliveTime < 0)
        throw new IllegalArgumentException();
    if (workQueue == null || threadFactory == null || handler == null)
        throw new NullPointerException();
    this.acc = System.getSecurityManager() == null ?
            null : AccessController.getContext();
    this.corePoolSize = corePoolSize;
    this.maximumPoolSize = maximumPoolSize;
    this.workQueue = workQueue;
    this.keepAliveTime = unit.toNanos(keepAliveTime);
    this.threadFactory = threadFactory;
    this.handler = handler;
}

接下来我们来深入的了解一下ThreadPoolExecutor中的这7大参数

  • corePoolSize:核心线程数,即线程池中的常驻核心线程数
    • 在创建了线程池后,当有请求任务来之后,就会安排池中的线程去执行请求任务,近似理解为今日当值线程。
    • 当线程池中的线程数目达到corePoolSize后,就会把到达的任务放到缓存队列当中。
  • maximumPoolSize:线程池能够容纳同时执行的最大线程数,此值必须大于等于1
    • 相当于扩容后的线程数,代表着这个线程池能容纳的最多线程数。
  • keepAliveTime:多余的空闲线程的存活时间。
    • 当前线程池数量超过corePoolSize时,当空闲时间达到keepAliveTime值时,多余空闲线程会被销毁直到只剩下corePoolSize个线程为止。
    • 默认情况下,只有当线程池中的线程数大于 corePoolSize 时,keepAliveTime 才会起作用。
  • unit:keepAliveTime的单位。
  • workQueue:任务队列,被提交但尚未被执行的任务。(类似于银行里面的候客区)
    • LinkedBlockingQueue:链表阻塞队列
    • SynchronousBlockingQueue:同步阻塞队列
  • threadFactory:表示生成线程池中工作线程的线程工厂,用于创建线程一般用默认的即可。
  • handler:拒绝策略,表示当工作队列满了并且工作线程大于线程池的最大线程数(maximumPoolSize3)时,如何来拒绝请求所执行的 Runnable的策略。比如银行中当营业窗口和等候区中都满了的时候,就需要设置拒绝策略。

image-20221015151732402

线程池的4种拒绝策略

工作线程和等待队列都已经排满了,再也塞不下新任务了,无法继续为新任务服务时我们就需要使用拒绝策略机制合理的处理这个问题。

JDK拒绝策略:

  • AbortPolicy:默认的拒绝策略,直接抛出 RejectedExecutionException异常阻止系统正常运知。
  • CallerRunsPolicy:"调用者运行"一种调节机制,该策略既不会抛弃任务,也不会抛出异常,而是将某些任务回退到调用者,从而降低新任务的流量。
  • DiscardOldestPolicy:抛弃队列中等待最久的任务,然后把当前任务加入队列中尝试再次提交当前任务。
  • DiscardPolicy:直接丢弃任务,不予任何处理也不抛出异常。如果允许任务丢失,这是最好的一种方案。
    以上内置拒绝策略均实现了RejectedExecutionHandler接口。

线程池底层工作原理

线程池运行架构图

image-20221015152446280

  1. 在创建了线程池后,等待提交过来的任务请求。

  2. 当调用execute()方法添加一个请求任务时,线程池会做如下判断:

    1. 如果正在运行的线程数量小于corePoolSize,那么马上创建线程运行这个任务;
    2. 如果正在运行的线程数量大于或等于corePoolSize,那么将这个任务放入队列;
    3. 如果这时候队列满了且正在运行的线程数量还小于maximumPoolSize,那么还是要创建非核心线程立刻运行这个任务;
    4. 如果队列满了且正在运行的线程数量大于或等于maximumPoolSize,那么线程池会启动饱和拒绝策略来执行。
  3. 当一个线程完成任务时,它会从队列中取下一个任务来执行。

  4. 当一个线程无事可做超过一定的时间(keepAliveTime)时,线程池会判断:

    1. 如果当前运行的线程数大于corePoolSize,那么这个线程就被停掉。
    2. 然后线程池的所有任务完成后它最终会收缩到corePoolSize的大小。

以顾客去银行办理业务为例,过一下线程池的底层工作原理

  1. 最开始假设来了两个顾客,因为 corePoolSize 为 2,因此这两个顾客直接能够去窗口办理
  2. 后面又来了三个顾客,因为 corePool 已经被顾客占用了,因此只有去候客区,也就是阻塞队列中等待
  3. 后面的人又陆陆续续来了,候客区可能不够用了,因此需要申请增加处理请求的窗口,这里的窗口指的是线程池中的线程数,以此来解决线程不够用的问题
  4. 假设受理窗口已经达到最大数,并且请求数还是不断递增,此时候客区和线程池都已经满了,为了防止大量请求冲垮线程池,已经需要开启拒绝策略
  5. 临时增加的线程会因为超过了最大存活时间,就会销毁,最后从最大数削减到核心数

线程池实际生产中使用哪一个

(超级大坑警告)你在工作中单一的/固定数的/可变的这三种创建线程池的方法,你用哪个多?

答案是一个都不用,我们生产上只能使用自定义的。

那Executors中JDK已经给你提供了,为什么不用?

根据阿里巴巴的《Java开发手册》的并发控制章节中所提到的:

  • 【强制】线程资源必须通过线程池提供,不允许在应用中自行显式创建线程。

    说明:线程池的好处是减少在创建和销毁线程上所消耗的时间以及系统资源的开销,解决资源不足的问题。 如果不使用线程池,有可能造成系统创建大量同类线程而导致消耗完内存或者“过度切换”的问题。

  • 【强制】线程池不允许使用 Executors 去创建,而是通过 ThreadPoolExecutor 的方式,这样的处理方式让写的同学更加明确线程池的运行规则,规避资源耗尽的风险。

    说明:Executors 返回的线程池对象的弊端如下:

    • FixedThreadPool 和 SingleThreadPool: 允许的请求队列长度为 Integer.MAX_VALUE,可能会堆积大量的请求,从而导致 OOM。
    • CachedThreadPool: 允许的创建线程数量为 Integer.MAX_VALUE,可能会创建大量的线程,从而导致 OOM。

手写线程池

采用AbortPolicy默认拒绝策略

从上面我们知道,因为默认的 Executors 创建的线程池,底层都是使用LinkBlockingQueue 作为阻塞队列的,而 LinkBlockingQueue 虽然是有界的,但是它的界限是 Integer.MAX_VALUE 大概有 20 多亿,可以相当是无界的了,因此我们要使用ThreadPoolExecutor自己手动创建线程池,然后指定阻塞队列的大小。

下面我们创建一个 核心线程数为 2,最大线程数为 5,并且阻塞队列数为 3 的线程池,代码如下

public static void main(String[] args) throws InterruptedException, ExecutionException {
        // 手写线程池
        final Integer corePoolSize = 2;
        final Integer maximumPoolSize = 5;
        final Long keepAliveTime = 1L;
        // 自定义线程池,只改变了 LinkBlockingQueue 的队列大小
        ExecutorService executorService = new ThreadPoolExecutor(
                corePoolSize,
                maximumPoolSize,
                keepAliveTime,
                TimeUnit.SECONDS,
                new LinkedBlockingQueue<>(3),
                Executors.defaultThreadFactory(),
                new ThreadPoolExecutor.AbortPolicy());
        try {
            //然后使用for循环,模拟10个用户来进行请求,每个用户就是一个来自外部请求线程,让5个线程处理这10个请求
            for (int i = 1; i <= 10; i++) {
                final int tempInt = i;
                executorService.execute(() -> {
                    System.out.println(Thread.currentThread().getName()
                            + "\t 给用户:" + tempInt + " 办理业务");
                });
            }
        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            executorService.shutdown();
        }
}

但是在用户执行到第九个的时候,触发了异常,程序中断,结果如下:

pool-1-thread-1	 给用户:1 办理业务
pool-1-thread-5	 给用户:8 办理业务
pool-1-thread-4	 给用户:7 办理业务
pool-1-thread-3	 给用户:6 办理业务
pool-1-thread-2	 给用户:2 办理业务
pool-1-thread-4	 给用户:5 办理业务
pool-1-thread-1	 给用户:3 办理业务
pool-1-thread-5	 给用户:4 办理业务
java.util.concurrent.RejectedExecutionException: Task com.xjx. Executor$$Lambda$1/1096979270@3b9a45b3 rejected from java.util.concurrent.ThreadPoolExecutor@7699a589[Running, pool size = 5, active threads = 5, queued tasks = 0, completed tasks = 3]
	at java.util.concurrent.ThreadPoolExecutor$AbortPolicy.rejectedExecution(ThreadPoolExecutor.java:2063)
	at java.util.concurrent.ThreadPoolExecutor.reject(ThreadPoolExecutor.java:830)
	at java.util.concurrent.ThreadPoolExecutor.execute(ThreadPoolExecutor.java:1379)
	at com.xjx. Executor.main(Executor.java:28)

这是因为触发了拒绝策略,而我们设置的拒绝策略是默认的 AbortPolicy,它就是会直接抛出异常。触发条件是请求的线程大于阻塞队列大小 + 最大线程数 = 8 的时候,也就是说第9 个线程来获取线程池中的线程时,就会抛出异常从而报错退出。

采用CallerRunsPolicy 拒绝策略

当我们使用其它的拒绝策略时,比如采用 CallerRunsPolicy 拒绝策略,其也称为回退策略,就是把任务丢回原来的请求开启线程者,运行结果如下:

pool-1-thread-1	 给用户:1 办理业务
pool-1-thread-5	 给用户:8 办理业务
pool-1-thread-1	 给用户:3 办理业务
pool-1-thread-4	 给用户:7 办理业务
pool-1-thread-3	 给用户:6 办理业务
main	 给用户:9 办理业务
pool-1-thread-2	 给用户:2 办理业务
pool-1-thread-1	 给用户:5 办理业务
pool-1-thread-5	 给用户:4 办理业务
pool-1-thread-2	 给用户:10 办理业务

我们发现,输出的结果里面出现了 main 线程,因为线程池出发了拒绝策略,把任务回退到main 线程,然后 main 线程对任务进行处理,之后线程2把原来的任务执行完毕,继续执行之后来的任务。

采用 DiscardPolicy 拒绝策略
pool-1-thread-3	 给用户:6 办理业务
pool-1-thread-4	 给用户:7 办理业务
pool-1-thread-5	 给用户:8 办理业务
pool-1-thread-2	 给用户:2 办理业务
pool-1-thread-1	 给用户:1 办理业务
pool-1-thread-5	 给用户:5 办理业务
pool-1-thread-4	 给用户:4 办理业务
pool-1-thread-3	 给用户:3 办理业务

采用DiscardPolicy拒绝策略,线程池会自动把后面的任务(9.10)都直接丢弃,同时也不报异常,当任务无关紧要的时候,可以采用这个方式。

采用 DiscardOldestPolicy 拒绝策略

这个策略和刚刚差不多,但是它是把最近的队列中的任务替换掉也就是说队列中最前面的任务(3.4)丢弃掉。

pool-1-thread-1	 给用户:1 办理业务
pool-1-thread-5	 给用户:8 办理业务
pool-1-thread-1	 给用户:5 办理业务
pool-1-thread-1	 给用户:10 办理业务
pool-1-thread-4	 给用户:7 办理业务
pool-1-thread-3	 给用户:6 办理业务
pool-1-thread-2	 给用户:2 办理业务
pool-1-thread-5	 给用户:9 办理业务

线程池配置合理线程数

生产环境中如何配置corePoolSize和maximumPoolSize?怎么配置才更加合理呢?
这个是根据具体业务来配置的,分为 CPU 密集型和 IO 密集型:

  • CPU密集型
    CPU密集的意思是该任务需要大量的运算而没有阻塞,CPU一直全速运行。CPU密集任务只有在真正的多核CPU上才可能得到加速(通过多线程),而在单核CPU上,无论你开几个模拟的多线程该任务都不可能得到加速,因为CPU总的运算能力就那些。
    CPU密集型需要配置尽可能少的线程数量,其一般公式:(CPU核数+1)个线程的线程池
  • lO密集型
    由于IO密集型任务线程并不是一直在执行任务,则应配置尽可能多的线程,如CPU核数 * 2。
    ​IO密集型,即该任务需要大量的IO,即大量的阻塞。在单线程上运行IO密集型的任务会导致浪费大量的CPU运算能力,因为需要等待。所以在IO密集型任务中使用多线程可以大大的加速程序运行,即使在单核CPU上,这种加速主要就是利用了被浪费掉的阻塞时间。
    ​IO密集型任务,大部分线程都阻塞,故需要多配置线程数,参考公式:CPU核数/ (1-阻塞系数),阻塞系数在0.8~0.9之间
    比如8核CPU:8/(1-0.9)=80个线程数

死锁编码及定位分析

概念

死锁是指两个或多个以上的进程在执行过程中,因争夺资源而造成一种互相等待的现象,若无外力干涉那他们都将无法推进下去。如果资源充足,进程的资源请求都能够得到满足,死锁出现的可能性就很低,否则就会因争夺有限的资源而陷入死锁。

image-20221015164511730

产生死锁主要原因

  • 系统资源不足
  • 进程运行推进的顺序不合适
  • 资源分配不当

发生死锁的四个条件:

  • 互斥条件

    • 线程使用的资源至少有一个不能共享的。
    • 解决方法:把互斥的共享资源封装成可同时访问
  • 占有且等待

    • 至少有一个线程必须持有一个资源且正在等待获取一个当前被别的线程持有的资源。
    • 解决方法:进程请求资源时,要求它不占有任何其它资源,也就是它必须一次性申请到所有的资源,这种方式会导致资源效率低。
  • 资源不能被抢占

    • 解决方法:如果进程不能立即分配资源,要求它不占有任何其他资源,也就是只能够同时获得所有需要资源时,才执行分配操作
  • 循环等待。

    • 解决方法:对资源进行排序,要求进程按顺序请求资源

如何解决死锁问题?

破坏发生死锁的四个条件其中之一即可。

死锁代码演示

我们创建了一个资源类,然后让两个线程分别持有自己的锁,同时在尝试获取别人的锁,就会出现死锁现象。代码如下(根据发生死锁的四个条件):

import java.util.concurrent.TimeUnit;

class MyTask implements Runnable{
	private Object resourceA, resourceB;
	public MyTask(Object resourceA, Object resourceB) {
		this.resourceA = resourceA;
		this.resourceB = resourceB;
	}
	@Override
	public void run() {
		synchronized (resourceA) {
			System.out.println(String.format("%s 自己持有%s,尝试持有%s",// 
					Thread.currentThread().getName(), resourceA, resourceB));	
			try {
				TimeUnit.SECONDS.sleep(2);
			} catch (InterruptedException e) {
				e.printStackTrace();
			}	
			synchronized (resourceB) {
				System.out.println(String.format("%s 同时持有%s,%s",// 
						Thread.currentThread().getName(), resourceA, resourceB));
			}
		}
	}

}

public class DeadLockDemo {
	public static void main(String[] args) {
		Object resourceA = new Object();
		Object resourceB = new Object();

		new Thread(new MyTask(resourceA, resourceB),"Thread A").start();
		new Thread(new MyTask(resourceB, resourceA),"Thread B").start();
	}
}

输出结果:

Thread A 自己持有java.lang.Object@59d8d77,尝试持有java.lang.Object@7a15e6e6
Thread B 自己持有java.lang.Object@7a15e6e6,尝试持有java.lang.Object@59d8d77

这时就出现了死锁现象:程序卡死,未出现同时持有的字样。

如何排查死锁

当我们出现死锁的时候,首先需要使用jps命令查看运行的程序,定位进程号。

C:\Users\abc>jps -l
11968 com.xjx.concurrency.DeadLockDemo
6100 jdk.jcmd/sun.tools.jps.Jps
6204 Eclipse

再使用jstack查看堆栈信息,jstack 11968 。后面参数是jps输出的该类的pid
得到的结果如下

C:\Users\abc>jstack 11968
2022-09-29 02:42:46
Full thread dump Java HotSpot(TM) 64-Bit Server VM (25.251-b08 mixed mode):
......
Found one Java-level deadlock:
=============================
"Thread B":
  waiting to lock monitor 0x000000001e105dc8 (object 0x000000076b431d80, a java.lang.Object),
  which is held by "Thread A"
"Thread A":
  waiting to lock monitor 0x000000001c181828 (object 0x000000076b431d90, a java.lang.Object),
  which is held by "Thread B"
Java stack information for the threads listed above:
===================================================
"Thread B":
        at com.lun.concurrency.MyTask.run(DeadLockDemo.java:27)
        - waiting to lock <0x000000076b431d80> (a java.lang.Object)
        - locked <0x000000076b431d90> (a java.lang.Object)
        at java.lang.Thread.run(Thread.java:748)
"Thread A":
        at com.lun.concurrency.MyTask.run(DeadLockDemo.java:27)
        - waiting to lock <0x000000076b431d90> (a java.lang.Object)
        - locked <0x000000076b431d80> (a java.lang.Object)
        at java.lang.Thread.run(Thread.java:748)
Found 1 deadlock.

通过查看最后一行,我们看到 Found 1 deadlock,即存在一个死锁。

JVM面试题

JVM整体结构

image-20221016103613343

Java8之后的JVM内存结构

image-20221016103731466

上面的Eden区和两个Survivor区(一个0区一个1区)以及Old区都是堆区,MetaSpace区是方法区,这两个区是垃圾收集也就是GC的作用区域。

JVM 垃圾回收的时候如何确定垃圾?是否知道什么是GCRoots

什么是垃圾

简单来说就是内存中已经不再被使用的内存空间(存储的是对象)就是垃圾。或者说内存中的对象没有被任何变量所引用,那么这个对象就是垃圾,因为没有任何地方需要使用这个对象。我们只需要把一个原来引用一个对象的变量设置为null,那么其原来引用的对象如果没有被别的变量所引用,那么这个对象就会变成垃圾。

如何判断一个对象是否可以被回收

共有两种垃圾标记算法:

1.引用计数法

Java 中,引用和对象是有关联的。如果要操作对象则必须用到引用。因此,最简单的办法就是通过引用计数来判断一个对象是否可以回收。简单说,给对象中添加一个引用计数器,每当有一个地方引用它,则计数器值加1,每当有一个引用失效,则计数器值减1。任何时刻计数器值为零的对象就是不可能再被使用的,那么这个对象就是可回收对象也就是垃圾。但主流的Java虚拟机里面都没有选用这个方法,其中最主要的原因是它很难解决对象之间相互循环引用的问题。
image-20221016104830069

2.可达性分析算法

这个标记算法具体实现是枚举根节点做可达性分析,也可以叫做根搜索路径算法。为了解决引用计数法的循环引用个问题,JVM使用了可达性分析的方法。
image-20221016105221506
详细过程如下:
image-20221016105442958

什么是GCRoots

那么根节点是什么呢?在JVM里面我们通常叫它GCRoots,下面我们就来详细了解一下GCRoots。
所谓“GC roots”或者说tracing GC的“根集合”就是一组必须活跃的引用
基本思路就是通过一系列名为”GC Roots”的对象作为起始点,从这个被称为GC Roots的对象开始向下搜索,如果一个对象到GC Roots没有任何引用链相连时,则说明此对象不可用。也即给定一个集合的引用作为根出发,通过引用关系遍历对象图,能被遍历到的(可到达的)对象就被判定为存活;没有被遍历到的对象自然就被判定为死亡。

一个例子理解GC Roots

假设我们现在有三个实体,分别是 人,狗,毛衣。然后他们之间的关系是:人牵着狗,狗穿着毛衣,他们之间是强连接的关系。有一天人消失了,只剩下狗狗和毛衣。这个时候,把人想象成GC Roots,因为人和狗之间失去了绳子连接,那么狗可能被回收,也就是被警察抓起来,被送到流浪狗寄养所。如果狗和人之间一直有强连接,那么狗狗就不会被当成是流浪狗。

Java中可以作为GC Roots的对象
  • 虚拟机栈(栈帧中的局部变量区,也叫做局部变量表)中引用的对象。
  • 方法区中的类静态属性引用的对象。
  • 方法区中常量引用的对象。
  • 本地方法栈中JNI(Native方法)引用的对象。
GC Roots对象代码演示
public class GCRootDemo {
    //第二种:方法区中的类静态属性引用的对象
	private static GCRootDemo2 t2;
	//第三种:方法区中的常量引用,GC Roots也会以这个为起点,进行遍历
	private static final GCRootDemo3 t3 = new GCRootDemo3(8);
    public static void m1() {
		// 第一种:虚拟机栈中的引用对象
        GCRootDemo t1 = new GCRootDemo();
        System.gc();
        System.out.println("第一次 GC 完成");
    }
    public static void main(String[] args) {
        m1();
    }
}

JVM 参数调优

JVM的参数类型

  • 标配参数(从 JDK1.0 - Java12 都在,很稳定)
    • -version java -version
    • -help
  • X参数(了解)
    • -Xint:解释执行
    • -Xcomp:第一次使用就编译成本地代码
    • -Xmixed:混合模式
  • XX参数(重点)
    • Boolean 类型
      公式:-XX:+或者-属性名,+表示开启这个属性名对应的属性,-表示关闭
      • Case:-XX:-PrintGCDetails:表示关闭了 GC 详情输出
    • key-value 类型
      公式:-XX:属性名key=属性值value
      • case:-XX:MetaspaceSize=21807104:设置元空间的内存大小

如何查看一个正在运行中的java程序的某个jvm参数是否开启或者其具体值

首先jps -l查看正在运行中的java程序,得到Java程序进程id。
然后jinfo -flag 参数名 Java程序进程id来查看某个Java程序的某个jvm参数是否开启或者其具体值,例子如下:

布尔类型参数:jinfo -flag PrintGCDetails 111111
关闭了则为-XX:-PrintGCDetails
开启了则为-XX:+PrintGCDetails

KV键值对类型参数:jinfo -flag MetaspaceSize 111111
-XX:MetaspaceSize=128m

jinfo -flags Java程序进程id查看它的所有jvm参数

JVM的XX参数之XmsXmx

两个经典参数:

-Xms等价于-XX:InitialHeapSize,初始化堆的内存大小,默认物理内存1/64
-Xmx等价于-XX:MaxHeapSize,最大堆分配的内存大小,默认为物理内存1/4
所以它们虽然只有一个X但仍为XX类型的参数,-Xms和-Xmx不过只是它们的别名。

查看JVM参数值

  1. 查看JVM参数初始默认值:-XX:+PrintFlagsInitial

    • 这个命令主要是查看JVM参数初始默认值
    • 公式
      • java -XX:+PrintFlagsInitial -version
      • java -XX:+PrintFlagsInitial(重要参数)
  2. 查看JVM参数最终默认值:-XX:+PrintFlagsFinal

    • 查看修改更新之后的JVM参数最终值,因为JVM可能会根据我们的电脑内存来动态的修改JVM中各个内存区域所占内存大小。
    • 公式:java -XX:+PrintFlagsFinal
    • 会将 JVM 的各个结果都进行打印。如果是:=则表示当前参数值是被修改过的, = 表示没有修改过的即默认值。
  3. 打印命令行参数:-XX:+PrintCommandLineFlags

    • 这个命令可以认为是上面命令的简化版本,它的作用就是打印出 JVM 的默认的简单初始化参数。最重要的就是它最后一个参数会打印出当前JVM使用的垃圾回收器是哪一个。

      • -XX:InitialHeapSize=266376000 -XX:MaxHeapSize=4262016000 -XX:+PrintCommandLineFlags 
        -XX:+UseCompressedClassPointers -XX:+UseCompressedOops -XX:-UseLargePagesIndividualAllocation 
        -XX:+UseParallelGC
        

这三个都是JVM的参数,一般都是配合我们需要运行的类来进行使用的。如果不加运行的类,则默认就是JVM的默认参数,也就是程序运行时统一的默认参数。

查看堆内存大小

public class JVMMemorySizeDemo {
    public static void main(String[] args) throws InterruptedException {
        // 返回Java虚拟机中内存的总量
        long totalMemory = Runtime.getRuntime().totalMemory();
        // 返回Java虚拟机中试图使用的最大内存量
        long maxMemory = Runtime.getRuntime().maxMemory();
        System.out.println(String.format("TOTAL_MEMORY(-Xms): %d B, %.2f MB.", totalMemory, totalMemory / 1024.0 / 1024));
        System.out.println(String.format("MAX_MEMORY(-Xmx): %d B, %.2f MB.", maxMemory, maxMemory / 1024.0 / 1024));
    }
}

输出结果:

TOTAL_MEMORY(-Xms) = 257425408(字节)、245.5MB
MAX_MEMORY(-Xmx) = 3790077952(字节)、3614.5MB

-Xms意思是初始堆内存大小,默认为物理内存的1/64, -Xmx意思是最大堆内存大小,默认为系统物理内存的1/4。

-Xmn:设置年轻代大小

查看栈内存大小

-XX:Xss参数用于设置单个线程栈的大小,一般默认为512k~1024K,其等价于**-XX:ThreadStackSize**

如果我们在windos中查看这个参数的值的话,会发现它的值是0,这个0代表这个参数的值使用的是JVM默认设置的值。这个默认值取决于我们计算机虚拟内存的大小。

查看元空间MetaspaceSize大小

-XX:MetaspaceSize 设置元空间大小

元空间和永久代类似,都是对JVM规范中方法区的实现。不过元空间与永久代之间最大的区别在于:元空间并不在虚拟机中,而是使用本地内存。因此,默认情况下,元空间的大小仅受本地内存限制,类的元数据放入native memory,字符串池和类的静态变量放入java堆中,这样可以加载多少类的元数据就不再由MaxPermSize控制,而由系统的实际可用空间来控制。

为了防止在频繁的实例化对象的时候,让元空间出现 OOM,因此可以把元空间设置的大一些。

查看GC收集日志

-XX:+PrintGCDetails 参数会输出详细GC收集日志信息

设置参数 -Xms10m -Xmx10m -XX:+PrintGCDetails 运行以下程序

import java.util.concurrent.TimeUnit;

public class PrintGCDetailsDemo {
	public static void main(String[] args) throws InterruptedException {
		byte[] byteArray = new byte[10 * 1024 * 1024];
		TimeUnit.SECONDS.sleep(Integer.MAX_VALUE);
	}
}

输出结果:

[GC (Allocation Failure) [PSYoungGen: 778K->480K(2560K)] 778K->608K(9728K), 0.0029909 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
[GC (Allocation Failure) [PSYoungGen: 480K->480K(2560K)] 608K->616K(9728K), 0.0007890 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
[Full GC (Allocation Failure) [PSYoungGen: 480K->0K(2560K)] [ParOldGen: 136K->518K(7168K)] 616K->518K(9728K), [Metaspace: 2644K->2644K(1056768K)], 0.0058272 secs] [Times: user=0.00 sys=0.00, real=0.01 secs] 
[GC (Allocation Failure) [PSYoungGen: 0K->0K(2560K)] 518K->518K(9728K), 0.0002924 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
[Full GC (Allocation Failure) [PSYoungGen: 0K->0K(2560K)] [ParOldGen: 518K->506K(7168K)] 518K->506K(9728K), [Metaspace: 2644K->2644K(1056768K)], 0.0056906 secs] [Times: user=0.01 sys=0.00, real=0.01 secs] 
Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
	at com.lun.jvm.PrintGCDetailsDemo.main(PrintGCDetailsDemo.java:9)
Heap
 PSYoungGen      total 2560K, used 61K [0x00000000ffd00000, 0x0000000100000000, 0x0000000100000000)
  eden space 2048K, 3% used [0x00000000ffd00000,0x00000000ffd0f748,0x00000000fff00000)
  from space 512K, 0% used [0x00000000fff00000,0x00000000fff00000,0x00000000fff80000)
  to   space 512K, 0% used [0x00000000fff80000,0x00000000fff80000,0x0000000100000000)
 ParOldGen       total 7168K, used 506K [0x00000000ff600000, 0x00000000ffd00000, 0x00000000ffd00000)
  object space 7168K, 7% used [0x00000000ff600000,0x00000000ff67ea58,0x00000000ffd00000)
 Metaspace       used 2676K, capacity 4486K, committed 4864K, reserved 1056768K
  class space    used 285K, capacity 386K, committed 512K, reserved 1048576K

常用基础参数SurvivorRatio

调节新生代中 eden 和 S0、S1的空间比例,默认为 -XX:SuriviorRatio=8,所以Eden:S0:S1 = 8:1:1
假如设置成 -XX:SurvivorRatio=4,则为 Eden:S0:S1 = 4:1:1
SurvivorRatio值就是设置eden区的比例占多少,S0和S1相同。

常用基础参数NewRatio

配置年轻代new 和老年代old 在堆结构的占比
默认:-XX:NewRatio=2 新生代占1,老年代2,年轻代占整个堆的1/3
-XX:NewRatio=4:新生代占1,老年代占4,年轻代占整个堆的1/5,
NewRadio值就是设置老年代的占比,剩下的1个是新生代。
新生代如果特别小,会造成频繁的进行GC收集。

常用基础参数MaxTenuringThreshold

-XX:MaxTenuringThreshold这个参数用于设置垃圾最大年龄,即晋升到老年代的对象年龄。
SurvivorTo和SurvivorFrom互换,原SurvivorTo成为下一次GC时的SurvivorFrom区,部分对象会在From和To区域中复制来复制去,如此交换15次(由JVM参数MaxTenuringThreshold决定,这个参数默认为15),最终如果还是存活,就存入老年代。
这里就是调整这个交换次数的。默认是15,并且设置的值需要在 0~15之间。
如果设置为0的话,则年轻对象不经过Survivor区,直接进入老年代。对于年老代比较多的应用,可以提高效率。如果将此值设置为一个较大的值,则年轻代对象会在Survivor区进行多次复制,这样可以增加对象再年轻代的存活时间,增加在年轻代即被回收的几率。

Java中的引用

image-20221016182000895

强引用Reference

Reference类以及继承派生的类。
当内存不足,JVM开始垃圾回收,对于强引用的对象,就算是出现了OOM也不会对该对象进行回收,死都不收。
这样定义的默认就是强应用Object obj1 = new Object();
强引用是我们最常见的普通对象引用,只要还有强引用指向一个对象,就能表明对象还“活着”,垃圾收集器不会碰这种对象。在Java中最常见的就是强引用,把一个对象赋给一个引用变量,这个引用变量就是一个强引用。当一个对象被强引用变量引用时,它处于可达状态,也就是说它是不可能被垃圾回收机制回收的,即使该对象以后永远都不会被用到JVM也不会回收。因此强引用是造成Java内存泄漏的主要原因之一。
对于一个普通的对象,如果没有其他的引用关系,只要超过了引用的作用域或者显式地将相应(强)引用赋值为 null一般认为就是可以被垃圾收集的了(当然具体回收时机还是要看垃圾收集策略)。

强引用代码演示
public class StrongReferenceDemo {
        public static void main(String[] args) {
			// 这样定义的默认就是强应用
            Object obj1 = new Object();
			// 使用第二个引用,指向刚刚创建的 Object 对象
            Object obj2 = obj1;
			// 置空
            obj1 = null;
			// 垃圾回收
            System.gc();
            System.out.println(obj1);
            System.out.println(obj2);
        }
}

输出结果如下:

null
java.lang.Object@14ae5a5

输出结果我们能够发现,即使 obj1 被设置成了 null,然后调用 gc 进行回收,但是也没有回收实例出来的对象,obj2还是能够指向该地址,也就是说垃圾回收器,并没有将该对象进行垃圾回收。

软引用SoftReference

软引用是一种相对强引用弱化了一些的引用,需要用java.lang.ref.SoftReference类来实现,可以让对象豁免一些垃圾收集。

对于只有软引用的对象来说:

  • 当系统内存充足时它不会被回收
  • 只有当系统内存不足时它才会被回收

软引用通常用在对内存敏感的程序中,比如高速缓存就有用到软引用,内存够用的时候就保留,不够用就回收!

软引用代码演示

下面的代码我们写了两个方法,一个是内存够用的时候,一个是内存不够用的时候。

public class SoftReferenceDemo {
    /**
     * 内存够用的时候
     * -XX:+PrintGCDetails
     */
    public static void softRefMemoryEnough() {
        // 创建一个强应用
        Object o1 = new Object();
        // 创建一个软引用
        SoftReference<Object> softReference = new SoftReference<>(o1);
        System.out.println(o1);
        System.out.println(softReference.get());
        o1 = null;
        // 手动GC
        System.gc();
        System.out.println(o1);
        System.out.println(softReference.get());
    }
    /**
     * JVM配置,故意产生大对象并配置小的内存,让它的内存不够用了导致OOM,看软引用的回收情况
     * -Xms5m -Xmx5m -XX:+PrintGCDetails
     */
    public static void softRefMemoryNoEnough() {
        System.out.println("========================");
        // 创建一个强应用
        Object o1 = new Object();
        // 创建一个软引用
        SoftReference<Object> softReference = new SoftReference<>(o1);
        System.out.println(o1);
        System.out.println(softReference.get());
        o1 = null;
        // 模拟OOM自动GC
        try {
            // 创建30M的大对象
            byte[] bytes = new byte[30 * 1024 * 1024];
        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            System.out.println(o1);
            System.out.println(softReference.get());
        }
    }
    
    public static void main(String[] args) {
        softRefMemoryEnough();
        //softRefMemoryNoEnough();
    }
}

内存充足输出结果在下面。我们会发现内存足够时首先输出的是o1和 软引用的 softReference,我们都能够看到值。然后我们把 o1 设置为 null,执行手动 GC 后,我们发现 softReference 的值还存在,说明内存充足的时候,软引用的对象不会被回收

java.lang.Object@15db9742
java.lang.Object@15db9742
[GC (System.gc()) [PSYoungGen: 2621K->728K(76288K)] 2621K->736K(251392K), 0.0011732 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
[Full GC (System.gc()) [PSYoungGen: 728K->0K(76288K)] [ParOldGen: 8K->519K(175104K)] 736K->519K(251392K), [Metaspace: 2646K->2646K(1056768K)], 0.0048782 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
null
java.lang.Object@15db9742

内存不充足时输出结果在下面。我们发现o1和softReference都被回收了,因此说明,软引用关联的对象在内存不足的时候,会
自动回收。且回收后内存依然不足的话,还是会抛异常。

java.lang.Object@15db9742
java.lang.Object@15db9742
[GC (Allocation Failure) [PSYoungGen: ......
null
null
Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
	at com.lun.jvm.SoftReferenceDemo.softRefMemoryNotEnough(SoftReferenceDemo.java:44)
	at com.lun.jvm.SoftReferenceDemo.main(SoftReferenceDemo.java:58)
Heap
 PSYoungGen......
软引用的使用场景

场景:假如有一个应用需要读取大量的本地图片

  • 如果每次读取图片都从硬盘读取则会严重影响性能

  • 如果一次性全部加载到内存中,又可能造成内存溢出。

此时使用软引用可以解决这个问题。

设计思路:使用HashMap来保存图片的路径和相应图片对象关联的软引用之间的映射关系,在内存不足时,JVM会自动回收这些缓存图片对象所占的空间,从而有效地避免了OOM的问题

Map<String, SoftReference<String>> imageCache = new HashMap<String, SoftReference<Bitmap>>();

弱引用WeakReference

弱引用需要用java.lang.ref.WeakReference类来实现,它比软引用的生存期更短。对于只有弱引用的对象来说,只要垃圾回收机制一运行不管JVM的内存空间是否足够,都会回收该对象占用的内存。也就是说不管内存是否够,只要有 GC 操作就会进行对弱引用进行回收。

弱引用代码演示

这里我觉得老师的代码是有问题的,因为他在弱引用之前创建了一个强引用,所以只要强引用不为空,这里弱引用仍然可以取出对象。所以我把代码改为下面的代码了。

import java.lang.ref.WeakReference;

public class WeakReferenceDemo {
    public static void main(String[] args) {
        WeakReference<Object> weakReference = new WeakReference<>(new Object());
        System.out.println(weakReference.get());
        System.gc();
        System.out.println(weakReference.get());
    }
}

输出结果:

java.lang.Object@1b6d3586
null
弱引用的使用场景

**WeakHashMap:**WeakHashMap 和 HashMap 类似,只不过它的 Key 是使用了弱引用的,也就是说,当执行 GC 的时候,HashMap中的key会进行回收。比如一些常常和底层打交道的,如mybatis,底层就应用到了 WeakHashMap。

这里我阅读了下源码,我们都知道HashMap底层真正存储键值对的是Node类的实例,HashMap则把存储键值对的这一个个Node实例存储起来(Node数组的形式)。但是WeakHashMap底层存储键值对的却并不是Node对象。而是自定义了私有的静态内部类Entry,源码如下(只看底层数据结构,没有粘贴其方法)

private static class Entry<K,V> extends WeakReference<Object> implements Map.Entry<K,V> {
    V value;
    final int hash;
    Entry<K,V> next;
}

其实它与HashMap的Node节点很像,因为Node类本身也实现了Map.Entry<K,V>这个接口,但是这个Entry关键的是又继承了WeakReference,这也是它起作用的最关键的一点。可以这么理解,WeakHashMap中存储的这一个个Entry就是一个个弱引用。这个Entry弱引用关联的就是我们向WeakHashMap中放的key

这里因为篇幅受限我们不过多深入,我把下面第二个例子中使用WeakHashMap为什么能实现回收的原因说一下:

WeakHashMap中创建了一个Object常量,这个常量就是用来标识一个键值对的key是否为空的,如果为空就把这个常量赋给这个key,那么每当想要取值时都会进行一次判断如果等于这个常量就会返回null,那么自然而然这个对象就会被回收了。

下面我们使用例子来测试一下。我们使用了两个方法,第一个一个是普通的 HashMap 方法
我们输入一个 Key-Value 键值对,然后让它的 key 置空,然后再查看结果

private static void myHashMap() {
	Map<Integer, String> map = new HashMap<>();
	Integer key = new Integer(1);
	String value = "HashMap";
	map.put(key, value);
	System.out.println(map);
	key = null;
}

最后输出结果为:

{1=HashMap}
{1=HashMap}

第二个是使用了 WeakHashMap

private static void myWeakHashMap() {
        Map<Integer, String> map = new WeakHashMap<>();
        Integer key = new Integer(1);
        String value = "WeakHashMap";
        map.put(key, value);
        System.out.println(map);
        key = null;
        System.gc();
        System.out.println(map);
}

输出结果如下

{1=WeakHashMap}
{}

从这里我们看到,对于普通的 HashMap 来说,key 置空并不会影响HashMap的键值对,因为这个属于强引用,不会被垃圾回收。但是 WeakHashMap,在进行 GC 操作后,弱引用的就会被回收。

虚引用PhantomReference

概念

虚引用又称为幽灵引用,需要java.lang.ref.PhantomReference类来实现。虚引用顾名思义就是形同虚设,与其他几种引用都不同,虚引用并不会决定对象的生命周期。如果一个对象仅持有虚引用,那么它就和没有任何引用一样,在任何时候都可能被垃圾回收器回收,它不能单独使用也不能通过它访问对象,虚引用必须和引用队列(ReferenceQueue)联合使用。虚引用的主要作用是跟踪对象被垃圾回收的状态。仅仅是提供了一种确保对象被finalize以后,做某些事情的机制,这也是虚引用存在的意义。

PhantomReference的get方法总是返回null,因此无法访问对应的引用对象。其意义在于说明一个对象已经进入finalization阶段,可以被gc回收,用来实现比fihalization机制更灵活的回收操作。换句话说,设置虚引用关联的唯一目的,就是在这个对象被收集器回收的时候收到一个系统通知或者后续添加进一步的处理。Java技术允许使用finalize()方法在垃圾收集器将对象从内存中清除出去之前做必要的清理工作。

如果程序发现某个虚引用已经被加入到引用队列,那么就可以在所引用的对象的内存被回收之前采取必要的行动这相当于是一种通知机制。当关联的引用队列中有数据的时候,意味着引用指向的堆内存中的对象被回收。通过这种方式,JVW允许我们在对象被销毁后,做一些我们自己想做的事情。

虚引用代码演示
import java.lang.ref.PhantomReference;
import java.lang.ref.ReferenceQueue;

public class PhantomReferenceDemo {
	public static void main(String[] args) throws InterruptedException {
		Object o1 = new Object();
		ReferenceQueue<Object> referenceQueue = new ReferenceQueue<>();
		PhantomReference<Object> phantomReference = new PhantomReference<>(o1, referenceQueue);
		System.out.println(o1);
		System.out.println(phantomReference.get());
		System.out.println(referenceQueue.poll());
		System.out.println("==================");
		o1 = null;
		System.gc();
		Thread.sleep(500) ;
		System.out.println(o1);
		System.out.println(phantomReference.get());
		System.out.println(referenceQueue.poll());
	}
}

输出结果:

java.lang.Object@15db9742
null
null
==================
null
null
java.lang.ref.PhantomReference@6d06d69c

ReferenceQueue引用队列

软引用,弱引用,虚引用在回收之前,可以在引用队列保存一下。只需要在初始化软引用,弱引用或者虚引用的时候,传入一个引用队列即可。这样就可以保证在进行 GC 回收之前被送至引用队列中。但ReferenceQueue是用来配合引用工作的,没有ReferenceQueue其他引用一样可以运行(包括虚引用,但虚引用如果不配合引用队列那么它就没任何价值了)。

import java.lang.ref.ReferenceQueue;
import java.lang.ref.WeakReference;
import java.util.concurrent.TimeUnit;

public class ReferenceQueueDemo {
    public static void main(String[] args) {
        Object o1 = new Object();
        // 创建引用队列
        ReferenceQueue<Object> referenceQueue = new ReferenceQueue<>();
        // 创建一个弱引用
        WeakReference<Object> weakReference = new WeakReference<>(o1, referenceQueue);
        System.out.println(o1);
        System.out.println(weakReference.get());
        // 取队列中的内容
        System.out.println(referenceQueue.poll());
        System.out.println("==================");
        o1 = null;
        System.gc();
        System.out.println("执行GC操作");
        try {
            TimeUnit.SECONDS.sleep(2);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println(o1);
        System.out.println(weakReference.get());
        // 取队列中的内容
        System.out.println(referenceQueue.poll());
    }
}

输出结果:

java.lang.Object@15db9742
java.lang.Object@15db9742
null
==================
执行GC操作
null
null
java.lang.ref.WeakReference@6d06d69c

GCRoots和四大引用小总结

  • 红色部分在垃圾回收之外,也就是强引用的

  • 蓝色部分:属于软引用,在内存不够的时候,才回收

  • 虚引用和弱引用:每次垃圾回收的时候,都会被干掉,但是它在干掉之前可能还会存在引用队列中,然后我们可以通过引用队列进行一些通知机制。

    image-20221016194643368

JVM中的溢出Error

经典错误

JVM 中常见的两个错误

  • StackoverFlowError :栈溢出
  • OutofMemoryError: 内存溢出
    • java.lang.OutOfMemoryError:java heap space
    • java.lang.OutOfMemoryError:GC overhead limit exceeeded
    • java.lang.OutOfMemoryError:Direct buffer memory
    • java.lang.OutOfMemoryError:unable to create new native thread
    • java.lang.OutOfMemoryError:Metaspace

这两种错误属于Throwable下的Error范畴,并不是异常(Exception)。

StackoverFlowError栈溢出错误

栈溢出通常出现在递归方法调用的时候,递归的太深了就会出现栈溢出错误,因为栈本身就是存放方法栈帧的地方,作用就是执行方法。

下面我们用最简单的一个递归调用,展现一下栈溢出错误,也就是过于深度的方法调用。栈一般是 512K,只要我们不断的深度方法调用,栈就会被撑破,出现栈溢出错误。

代码演示:

public class StackOverflowErrorDemo {
	public static void main(String[] args) {
		main(args);
	}
}

输出结果:

Exception in thread "main" java.lang.StackOverflowError
	at com.lun.jvm.StackOverflowErrorDemo.main(StackOverflowErrorDemo.java:6)
	at com.lun.jvm.StackOverflowErrorDemo.main(StackOverflowErrorDemo.java:6)
	at com.lun.jvm.StackOverflowErrorDemo.main(StackOverflowErrorDemo.java:6)
	...

OutOfMemoryError内存溢出错误

Java heap space堆空间内存溢出错误

我们如果在堆中创建了过多的对象,就会导致堆空间不够存储。下面我们修改JVM参数使堆内存大小为10M,然后直接创建一个80M的字节数组大对象,这样堆内存就会因为放不下这个大对象而报出堆空间内存溢出错误。

代码演示:

public class OOMEJavaHeapSpaceDemo {
	/**
	 * -Xms10m -Xmx10m
	 * @param args
	 */
	public static void main(String[] args) {
		byte[] array = new byte[80 * 1024 * 1024];
	}
}

输出结果:

Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
	at com.lun.jvm.OOMEJavaHeapSpaceDemo.main(OOMEJavaHeapSpaceDemo.java:6)
GC overhead limit exceeded

这个错误的意思是超出GC开销限制。

GC如果回收时间过长时会抛出OutOfMemroyError。过长的定义是,超过98%的时间用来做GC并且回收了不到2%的堆内存,连续多次GC 都只回收了不到2%的极端情况下才会抛出。

假如不抛出GC overhead limit错误会发生什么情况呢?那就是GC清理的这么点内存很快会再次填满,迫使cc再次执行。这样就形成恶性循环,CPU使用率一直是100%,而GC却没有任何成果。

代码演示:

这个异常出现的步骤就是,我们不断的向list中插入String对象,然后intern方法把其加入字符串常量池(在元空间中,不会回收),直到启动 GC 回收

import java.util.ArrayList;
import java.util.List;

public class OOMEGCOverheadLimitExceededDemo {
    /**
     * -Xms10m -Xmx10m -XX:MaxDirectMemorySize=5m
     * @param args
     */
    public static void main(String[] args) {
        int i = 0;
        List<String> list = new ArrayList<>();
        try {
            while(true) {
                list.add(String.valueOf(++i).intern());
            }
        } catch (Exception e) {
            System.out.println("***************i:" + i);
            e.printStackTrace();
            throw e;
        }
    }
}

输出结果

[GC (Allocation Failure) [PSYoungGen: 2048K->498K(2560K)] 2048K->1658K(9728K), 0.0033090 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
......*2
[GC (Allocation Failure) [PSYoungGen: 2410K->512K(2560K)] 6779K->6872K(9728K), 0.0058689 secs] [Times: user=0.09 sys=0.00, real=0.01 secs] 
[Full GC (Ergonomics) [PSYoungGen: 512K->0K(2560K)] [ParOldGen: 6360K->6694K(7168K)] 6872K->6694K(9728K), [Metaspace: 2651K->2651K(1056768K)], 0.0894928 secs] [Times: user=0.42 sys=0.00, real=0.09 secs] 
......*N
***************i:147041
[Full GC (Ergonomics) [PSYoungGen: 2047K->2047K(2560K)] [ParOldGen: 7050K->7048K(7168K)] 9098K->9096K(9728K), [Metaspace: 2670K->2670K(1056768K)], 0.0371397 secs] [Times: user=0.22 sys=0.00, real=0.04 secs] 
java.lang.OutOfMemoryError: GC overhead limit exceeded
[Full GC (Ergonomics) 	at java.lang.Integer.toString(Integer.java:401)
[PSYoungGen: 2047K->2047K(2560K)] [ParOldGen: 7051K->7050K(7168K)] 9099K->9097K(9728K), [Metaspace: 2676K->2676K(1056768K)], 0.0434184 secs] [Times: user=0.38 sys=0.00, real=0.04 secs] 
	at java.lang.String.valueOf(String.java:3099)
	at com.lun.jvm.OOMEGCOverheadLimitExceededDemo.main(OOMEGCOverheadLimitExceededDemo.java:19)
Exception in thread "main" java.lang.OutOfMemoryError: GC overhead limit exceeded
[Full GC (Ergonomics) [PSYoungGen: 2047K->0K(2560K)] [ParOldGen: 7054K->513K(7168K)] 9102K->513K(9728K), [Metaspace: 2677K->2677K(1056768K)], 0.0056578 secs] [Times: user=0.11 sys=0.00, real=0.01 secs] 
	at java.lang.Integer.toString(Integer.java:401)
	at java.lang.String.valueOf(String.java:3099)
	at com.lun.jvm.OOMEGCOverheadLimitExceededDemo.main(OOMEGCOverheadLimitExceededDemo.java:19)
Heap
 PSYoungGen      total 2560K, used 46K [0x00000000ffd00000, 0x0000000100000000, 0x0000000100000000)
  eden space 2048K, 2% used [0x00000000ffd00000,0x00000000ffd0bb90,0x00000000fff00000)
  from space 512K, 0% used [0x00000000fff80000,0x00000000fff80000,0x0000000100000000)
  to   space 512K, 0% used [0x00000000fff00000,0x00000000fff00000,0x00000000fff80000)
 ParOldGen       total 7168K, used 513K [0x00000000ff600000, 0x00000000ffd00000, 0x00000000ffd00000)
  object space 7168K, 7% used [0x00000000ff600000,0x00000000ff6807f0,0x00000000ffd00000)
 Metaspace       used 2683K, capacity 4486K, committed 4864K, reserved 1056768K
  class space    used 285K, capacity 386K, committed 512K, reserved 1048576K
Direct buffer memory

导致原因:Netty + NIO

写NIO程序时经常使用ByteBuffer来读取或者写入数据,这是一种基于通道(Channel)与缓冲区(Buffer)的IO方式,它可以使用Native函数库直接分配堆外内存,然后通过一个存储在Java堆里面的DirectByteBuffer对象作为这块内存的引用进行操作。这样能在一些场景中显著提高性能,因为避兔了在Java堆和Native堆中来回复制数据。

ByteBuffer.allocate(capability):第一种方式是分配VM堆内存,属于GC管辖范围,由于需要拷贝所以速度相对较慢。
ByteBuffer.allocateDirect(capability):第二种方式是分配OS本地内存,不属于GC管辖范围,由于不需要内存的拷贝,所以速度相对较快。
但如果不断分配本地内存,堆内存很少使用,那么JVM就不需要执行GC,DirectByteBuffer对象们就不会被回收,这时候堆内存充足,但本地内存可能已经使用光了,再次尝试分配本地内存就会出现OutOfMemoryError,那程序就直接崩溃了。

一句话说:本地内存不足,但是堆内存充足的时候,就会出现这个问题。

代码演示:

import java.nio.ByteBuffer;
import java.util.concurrent.TimeUnit;

public class OOMEDirectBufferMemoryDemo {
	/**
	 * -Xms5m -Xmx5m -XX:+PrintGCDetails -XX:MaxDirectMemorySize=5m
	 * @param args
	 * @throws InterruptedException
	 */
	public static void main(String[] args) throws InterruptedException {
		System.out.println(String.format("配置的maxDirectMemory: %.2f MB",// 
				sun.misc.VM.maxDirectMemory() / 1024.0 / 1024));
		TimeUnit.SECONDS.sleep(3);
		ByteBuffer bb = ByteBuffer.allocateDirect(6 * 1024 * 1024);
	}	
}

输出结果:

配置的maxDirectMemory: 5.00 MB
[GC (System.gc()) [PSYoungGen: 622K->504K(1536K)] 890K->820K(5632K), 0.0009753 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
[Full GC (System.gc()) [PSYoungGen: 504K->0K(1536K)] [ParOldGen: 316K->725K(4096K)] 820K->725K(5632K), [Metaspace: 3477K->3477K(1056768K)], 0.0072268 secs] [Times: user=0.00 sys=0.00, real=0.01 secs] 
Exception in thread "main" java.lang.OutOfMemoryError: Direct buffer memory
	at java.nio.Bits.reserveMemory(Bits.java:694)
	at java.nio.DirectByteBuffer.<init>(DirectByteBuffer.java:123)
	at java.nio.ByteBuffer.allocateDirect(ByteBuffer.java:311)
	at com.lun.jvm.OOMEDirectBufferMemoryDemo.main(OOMEDirectBufferMemoryDemo.java:20)
unable to create new native thread

这个异常的意思是不能够创建更多的新线程了,也就是说创建线程的上限达到了。

在高并发场景下应用到高并发请求服务器时,经常会出现异常java.lang.OutOfMemoryError:unable to create new native thread,准确说该native thread异常是与对应的平台有关的。

导致原因:

  • 应用创建了太多线程,一个应用进程创建太多的线程,超过系统承载极限。
  • 服务器并不允许你的应用程序创建这么多线程,linux系统默认运行单个进程可以创建的线程为1024个,如果应用创建超过这个数量,就会报 java.lang.OutOfMemoryError:unable to create new native thread

解决方法:

  1. 想办法降低你应用程序创建线程的数量,分析应用是否真的需要创建这么多线程,如果不是,改代码将线程数降到最低
  2. 对于有的应用,确实需要创建很多线程,远超过linux系统默认1024个线程限制,可以通过修改Linux服务器配置,扩大linux默认限制

代码演示:

public class OOMEUnableCreateNewThreadDemo {
    public static void main(String[] args) {
        for (int i = 0; ; i++) {
            System.out.println("************** i = " + i);
            new Thread(() -> {
                try {
                    TimeUnit.SECONDS.sleep(Integer.MAX_VALUE);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }, String.valueOf(i)).start();
        }
    }
}

上面程序在Linux OS(CentOS)运行,会出现下列的错误,线程数大概在900多个

Exception in thread "main" java.lang.OutOfMemoryError: unable to cerate new native thread
linux中线程上限调整步骤(服务器级别调参调优):

linux查看线程数命令:ulimit -u
修改系统线程限制数目

vim /etc/security/limits.d/90-nproc.conf

进入这个配置文件中就可以修改我们想要修改的用户的最大运行线程数

Metaspace

Metaspace叫做元空间也就是我们的方法区,存放的是类模板,类信息,常量池等

Metaspace是方法区在Hotspot 中的实现,它与持久代最大的区别在于:Metaspace并不在虚拟机内存中而是使用本地内存,也即在Java8中, classe metadata(the virtual machines internal presentation of Java class),被存储在叫做Metaspace native memory。

永久代(Java8后被元空间Metaspace取代了)存放了以下信息:

  • 虚拟机加载的类信息
  • 常量池
  • 静态变量
  • 即时编译后的代码

代码演示:

模拟Metaspace空间溢出,我们需要借助CGLib直接操作字节码运行时不断生成类往元空间灌,那么类占据的空间终究会超过Metaspace指定的空间大小的。

首先我们需要添加CGLib依赖

<!-- https://mvnrepository.com/artifact/cglib/cglib -->
<dependency>
    <groupId>cglib</groupId>
    <artifactId>cglib</artifactId>
    <version>3.2.10</version>
</dependency>
import java.lang.reflect.Method;
import net.sf.cglib.proxy.Enhancer;
import net.sf.cglib.proxy.MethodInterceptor;
import net.sf.cglib.proxy.MethodProxy;

public class OOMEMetaspaceDemo {
    // 静态类
    static class OOMObject {}

    /**
     * -XX:MetaspaceSize=10m -XX:MaxMetaspaceSize=10m
     * @param args
     */
    public static void main(final String[] args) {
        // 模拟计数多少次以后发生异常
        int i =0;
        try {
            while (true) {
                i++;
                // 使用Spring的动态字节码技术
                Enhancer enhancer = new Enhancer();
                enhancer.setSuperclass(OOMObject.class);
                enhancer.setUseCache(false);
                enhancer.setCallback(new MethodInterceptor() {
                    @Override
                    public Object intercept(Object o, Method method, Object[] objects, MethodProxy 									methodProxy) throws Throwable {
                        return methodProxy.invokeSuper(o, args);
                    }
                });
                enhancer.create();
            }
        } catch (Throwable e) {
            System.out.println("发生异常的次数:" + i);
            e.printStackTrace();
        } finally {
        }
    }
}

输出结果:

发生异常的次数:569
java.lang.OutOfMemoryError: Metaspace
	at net.sf.cglib.core.AbstractClassGenerator.generate(AbstractClassGenerator.java:348)
	at net.sf.cglib.proxy.Enhancer.generate(Enhancer.java:492)
	at net.sf.cglib.core.AbstractClassGenerator$ClassLoaderData.get(AbstractClassGenerator.java:117)
	at net.sf.cglib.core.AbstractClassGenerator.create(AbstractClassGenerator.java:294)
	at net.sf.cglib.proxy.Enhancer.createHelper(Enhancer.java:480)
	at net.sf.cglib.proxy.Enhancer.create(Enhancer.java:305)
	at com.lun.jvm.OOMEMetaspaceDemo.main(OOMEMetaspaceDemo.java:37)

垃圾收集器

GC 垃圾回收算法和垃圾收集器关系

正所谓天上飞的理念必然要有落地的实现(垃圾收集器就是GC垃圾回收算法的实现)。GC算法(引用计数/复制/标清/标整)是内存回收的方法论,垃圾收集器就是算法的落地实现。

GC 算法主要有以下几种:

  • 引用计数(几乎不用,无法解决循环引用的问题)
  • 复制拷贝(用于新生代)
  • 标记清除(用于老年代)
  • 标记整理(用于老年代)

因为目前为止还没有完美的收集器出现,只是针对具体应用选择最合适的收集器进行分代收集(哪个代用什么收集器)。

4种主要垃圾收集器

  • Serial:串行回收 -XX:+UseSeriallGC

  • Parallel:并行回收 -XX:+UseParallelGC

  • CMS:并发标记清除

  • G1

  • ZGC:(java11出现的,未来的主流)

    image-20221018220956922

串行垃级回收器(Serial):串行垃圾回收器,它为单线程环境设计,只使用一个线程进行垃圾收集,会暂停所有的用户线程,只有当垃圾回收完成时,才会重新唤醒主线程继续执行,所以不适合服务器环境。
并行垃圾回收器(Parallel):并行垃圾收集器,多个垃圾收集线程并行工作,此时用户线程也是阻塞的,适用于科学计算 / 大数据处理等弱交互场景,也就是说Serial 和 Parallel其实是类似的,不过是多了几个线程进行垃圾收集,但是主线程都会被暂停,但是并行垃圾收集器处理时间肯定比串行的垃圾收集器要更短。
并发垃圾回收器(CMS) :并发标记清除,用户线程和垃圾收集线程同时执行(不一定是并行,可能是交替执行),不需要停顿用户线程,互联网公司都在使用,适用于响应时间有要求的场景。并发是可以有交互的,也就是说可以一边进行收集,一边执行应用程序。
G1垃圾回收器 :G1垃圾回收器将堆内存分割成很多很多不同的区域,然后并发的对其进行垃圾回收。
串行,并行,并发GC小总结(G1稍后)

垃圾收集器总结

注意:并行垃圾回收在单核 CPU 下可能会更慢

image-20221018221451641

查看默认的垃圾收集器

使用下面 JVM 命令,查看配置的初始参数

java -XX:+PrintCommandLineFlags -version

输出结果如下:

-XX:InitialHeapSize=266376000 -XX:MaxHeapSize=4262016000 -XX:+PrintComm
andLineFlags -XX:+UseCompressedClassPointers -XX:+UseCompressedOops -X
X:-UseLargePagesIndividualAllocation -XX:+UseParallelGC

从结果看到-XX:+UseParallelGC,也就是说默认的垃圾收集器是并行垃圾回收器。

或者jps -l,得出Java程序号,然后jinfo -flags Java程序号也可以查出默认垃圾收集器。

JVM默认的垃圾收集器

Java中一共有7大垃圾收集器

image-20221018222323016

  • 年轻代GC收集器
    • UserSerialGC(Serial Copying):串行垃圾收集器
    • UserParallelGC(Parallel Scavenge):并行垃圾收集器
    • UseParNewGC(ParNew):年轻代的并行垃圾收集器
  • 老年代GC收集器
    • UserSerialOldGC(Serial Old):串行老年代垃圾收集器(已经被移除)
    • UseParallelOldGC(Parallel Compacting):老年代的并行垃圾收集器
    • UseConcMarkSweepGC(CMS):并发标记清除垃圾收集器
  • 老嫩通吃
    • UseG1GC:G1垃圾收集器

底层源码中就有这七种垃圾收集器

image-20221018222104763

下图是年轻代和老年代垃圾收集器使用之间的搭配。

image-20221018222601305

GC之约定参数说明

DefNew:Default New Generation
Tenured:Old我认为等同于Default New Generation因为它们都是串行收集器
ParNew:Parallel New Generation
PSYoungGen:Parallel Scavenge
ParOldGen:Parallel Old Generation

JVM中的 Server 和 Client 模式

使用范围:一般使用Server模式,Client模式基本不会使用

操作系统

  • 32位的Window操作系统,不论硬件如何都默认使用Client的JVM模式

  • 32位的其它操作系统,2G内存同时有2个cpu以上用Server模式,低于该配置还是Client模式

  • 64位只有Server模式

通过java -version命令我们可以查到我们电脑的位数以及JVM使用模式

java version "1.8.0_251"
Java(TM) SE Runtime Environment (build 1.8.0_251-b08)
Java HotSpot(TM) 64-Bit Server VM (build 25.251-b08, mixed mode)

新生代下的垃圾收集器

Serial串行收集器

它是一个单线程的收集器,在进行垃圾收集时候,必须暂停其他所有的工作线程直到它收集结束。

image-20221018223136136

STW: Stop The World

串行收集器是最古老,最稳定以及效率高的收集器,只使用一个线程去回收但其在进行垃圾收集过程中可能会产生较长的停顿(Stop-The-World”状态)。虽然在收集垃圾过程中需要暂停所有其他的工作线程,但是它简单高效,对于限定单个CPU环境来说,没有线程交互的开销可以获得最高的单线程垃圾收集效率,因此Serial垃圾收集器依然是java虚拟机运行在Client模式下默认的新生代垃圾收集器。

对应JVM参数是:-XX:+UseSerialGC

开启后会使用:Serial(Young区用) + Serial Old(Old区用)的收集器组合

这个组合表示:新生代、老年代都会使用串行回收收集器,新生代使用复制算法,老年代使用标记-整理算法

ParNew并行收集器

并行收集器使用多线程进行垃圾回收,在垃圾收集时,也会Stop-The-World暂停其他所有的工作线程直到它收集结束。

image-20221018223435590

ParNew收集器其实就是Serial收集器新生代的并行多线程版本,最常见的应用场景是配合老年代的CMS GC工作(但不是其默认工作场景),其余的行为和Seria收集器完全一样,ParNew垃圾收集器在垃圾收集过程中同样也要暂停所有其他的工作线程。它是很多Java虚拟机运行在Server模式下新生代的默认垃圾收集器。

常用对应JVM参数:-XX:+UseParNewGC启用ParNew收集器,只影响新生代的收集,不影响老年代。

开启上述参数后,会使用:ParNew(Young区)+ Serial Old的收集器组合,新生代使用复制算法,老年代采用标记-整理算法

但是,ParNew+Tenured这样的搭配,Java8已经不再被推荐

Java HotSpot™64-Bit Server VM warning:
Using the ParNew young collector with the Serial old collector is deprecated and will likely be removed in a future release.

备注:-XX:ParallelGCThreads参数可以限制线程数量,默认是开启和CPU数目相同的线程数。

Parallel并行收集器

Parallel(Parallel Scavenge)收集器类似于ParNew也是一个新生代垃圾收集器,使用复制算法,也是一个并行的多线程的垃圾收集器,俗称吞吐量优先收集器。一句话:串行收集器在新生代和老年代的并行化。因为 Serial 和 ParNew 都不推荐使用了,因此现在新生代默认使用的是 ParallelScavenge,也就是新生代和老年代都是使用并行。

image-20221018223738104

它重点关注的是:

可控制的吞吐量(Thoughput=运行用户代码时间(运行用户代码时间+垃圾收集时间),也即比如程序运行100分钟,垃圾收集时间1分钟,吞吐量就是99% )。高吞吐量意味着高效利用CPU的时间,它多用于在后台运算而不需要太多交互的任务。

自适应调节策略也是ParallelScavenge收集器与ParNew收集器的一个重要区别。(自适应调节策略:虚拟机会根据当前系统的运行情况收集性能监控信息,动态调整这些参数以提供最合适的停顿时间(-XX:MaxGCPauseMillis)或最大的吞吐量。

常用JVM参数:-XX:+UseParallelGC或-XX:+UseParallelOldGC(可互相激活)使用Parallel Scanvenge收集器。

开启该参数后:新生代使用复制算法,老年代使用标记-整理算法。

多说一句:-XX:ParallelGCThreads=数字N,表示启动多少个并行的GC线程(cpu>8 则N= 5/8,cpu<8 则N=实际个数)

老年代下的垃圾收集器

SerialOld收集器

Serial Old是Serial垃圾收集器的老年代版本,它同样是一个单线程的收集器,使用标记-整理算法,这个收集器也主要是运行在 Client默认的java虚拟机默认的年老代垃圾收集器。

在Server模式下,主要有两个用途(了解,版本已经到8及以后):

  • 在JDK1.5之前版本中与新生代的Parallel Scavenge 收集器搭配使用。(Parallel Scavenge + Serial Old )
  • 作为老年代版中使用CMS收集器的后备垃圾收集方案。

配置方法: -XX:+UseSerialOldGC
在Java8中,-XX:+UseSerialOldGC不起作用,也就是说之后的版本SerialOld收集器已经被淘汰了。

ParallelOld收集器

Parallel Old收集器是Parallel Scavenge的老年代版本,使用多线程的标记-整理算法,Parallel Old收集器在JDK1.6才开始提供。

在JDK1.6之前,新生代使用ParallelScavenge收集器只能搭配年老代的Serial Old收集器,只能保证新生代的吞吐量优先,无法保证整体的吞吐量。在JDK1.6之前(Parallel Scavenge + Serial Old )

Parallel Old 正是为了在年老代同样提供吞吐量优先的垃圾收集器,如果系统对吞吐量要求比较高,JDK1.8后可以优先考虑新生代Parallel Scavenge和年老代Parallel Old收集器的搭配策略。在JDK1.8及以后〈Parallel Scavenge + Parallel Old )

JVM常用参数:-XX:+UseParallelOldGC使用Parallel Old收集器,设置该参数后,新生代为ParallelScavenge+老年代为Parallel Old。

CMS收集器

CMS收集器(Concurrent Mark Sweep:并发标记清除)是一种以获取最短回收停顿时间为目标的收集器。

适合应用在互联网站或者B/S系统的服务器上,这类应用尤其重视服务器的响应速度,希望系统停顿时间最短。

CMS非常适合地内存大、CPU核数多的服务器端应用,也是G1出现之前大型应用的首选收集器。

image-20221019100454595

Concurrent Mark Sweep:并发标记清除,并发收集低停顿,并发指的是垃圾收集线程与用户线程一起执行。
开启该收集器的JVM参数:-XX:+UseConcMarkSweepGC,开启该参数后会自动将-XX:+UseParNewGC打开。开启该参数后,使用ParNew(Young区用)+ CMS(Old区用)+ Serial Old的收集器组合,Serial Old将作为CMS出错的后备收集器。

CMS收集器收集垃圾的过程分为四个步骤:

  • 初始标记(CMS initial mark) - 只是标记一下GC Roots能直接关联的对象,速度很快,仍然需要暂停所有的工作线程。

  • 并发标记(CMS concurrent mark)和用户线程一起 - 进行GC Roots跟踪的过程,和用户线程一起工作,不需要暂停工作线程。主要标记过程,标记全部对象。

  • 重新标记(CMS remark)- 为了修正在并发标记期间,因用户程序继续运行而导致标记产生变动的那一部分对象的标记记录,仍然需要暂停所有的工作线程。由于并发标记时,用户线程依然运行,因此在正式清理前,再做修正。

  • 并发清除(CMS concurrent sweep) - 清除GCRoots不可达对象,和用户线程一起工作,不需要暂停工作线程。基于标记结果,直接清理对象,由于耗时最长的并发标记和并发清除过程中,垃圾收集线程可以和用户现在一起并发工作,所以总体上来看CMS 收集器的内存回收和用户线程是一起并发地执行。

image-20221019100735123

优点: 并发收集低停顿。

缺点: 并发执行,对CPU资源压力大,采用的标记清除算法会导致大量碎片。

由于并发进行,CMS在收集与应用线程会同时会增加对堆内存的占用,也就是说,CMS必须要在老年代堆内存用尽之前完成垃圾回收,否则CMS回收失败时,将触发担保机制,串行老年代收集器将会以STW的方式进行一次GC,从而造成较大停顿时间。

标记清除算法无法整理空间碎片,老年代空间会随着应用时长被逐步耗尽,最后将不得不通过担保机制对堆内存进行压缩。CMS也提供了参数-XX:CMSFullGCsBeForeCompaction(默认O,即每次都进行内存整理)来指定多少次CMS收集之后,进行一次压缩的Full GC。

为什么新生代采用复制算法,老年代采用标整算法

新生代使用复制算法

因为新生代对象的生存时间比较短,80%的都要回收的对象,采用标记-清除算法则内存碎片化比较严重,采用复制算法可以灵活高效且便与整理空间。

老年代采用标记整理

标记整理算法主要是为了解决标记清除算法存在内存碎片的问题,又解决了复制算法两个 Survivor 区的问题,因为老年代的空间比较大,不可能采用复制算法,否则特别占用内存空间

如何选择垃圾收集器

组合的选择
  • 单CPU或者小内存,单机程序
    • -XX:+UseSerialGC
  • 多CPU,需要最大的吞吐量,如后台计算型应用
    • -XX:+UseParallelGC(这两个相互激活)
    • -XX:+UseParallelOldGC
  • 多CPU,追求低停顿时间,需要快速响应如互联网应用
    • -XX:+UseConcMarkSweepGC
    • -XX:+ParNewGC
参数新生代垃圾收集器新生代算法老年代垃圾收集器老年代算法
-XX:+UseSerialGCSerialGC复制SerialOldGC标记整理
-XX:+UseParNewGCParNew复制SerialOldGC标记整理
-XX:+UseParallelGCParallel [Scavenge]复制Parallel Old标记整理
XX:+UseConcMarkSweepGCParNew复制CMS + Serial Old的收集器组合,Serial Old作为CMS出错的后备收集器标记清除
-XX:+UseG1GCG1整体上采用标记整理算法局部复制

G1 垃圾收集器

以前收集器特点:

年轻代和老年代是各自独立且连续的内存块。年轻代收集使用单独的eden+s0+s1进行复制算法;老年代收集必须扫描整个老年代区域。都是以尽可能少而快速地执行GC为设计原则。

G1概念与特点

G1 (Garbage-First)收集器是一款面向服务端应用的收集器,应用在多处理器和大容量内存环境中,在实现高吞吐量的同时,尽可能的满足垃圾收集暂停时间的要求。另外,它还具有以下特性:

  • 像CMS收集器一样,能与应用程序线程并发执行。
  • 整理空闲空间更快。
  • 需要更多的时间来预测GC停顿时间。
  • 不希望牺牲大量的吞吐性能。
  • 不需要更大的Java Heap。

G1收集器的设计目标是取代CMS收集器,它同CMS相比,在以下方面表现的更出色:

  • G1是一个有整理内存过程的垃圾收集器,不会产生很多内存碎片。

  • G1的Stop The World(STW)更可控,G1在停顿时间上添加了预测机制,用户可以指定期望停顿时间。

CMS垃圾收集器虽然减少了暂停应用程序的运行时间,但是它还是存在着内存碎片问题。于是,为了去除内存碎片问题,同时又保留CMS垃圾收集器低暂停时间的优点,JAVA7发布了一个新的垃圾收集器-G1垃圾收集器。

G1是在2012年才在jdk1.7中可用。oracle官方在JDK9中将G1变成默认的垃圾收集器以替代CMS。它是一款面向服务端应用的收集器,主要应用在多CPU和大内存服务器环境下,极大的减少垃圾收集的停顿时间,全面提升服务器的性能,逐步替换java8以前的CMS收集器。

主要改变是Eden,Survivor和Tenured等内存区域不再是连续的了,而是变成了一个个大小一样的region ,每个region从1M到32M不等。一个region有可能是Eden,Survivor,Tenured内存区域或者G1特有的Humongous区。

特点:

  • G1能充分利用多CPU、多核环境硬件优势,尽量缩短STW。
  • G1整体上采用标记-整理算法,局部是通过复制算法,不会产生内存碎片。
  • 宏观上看G1之中不再区分年轻代和老年代。把内存划分成多个独立的子区域(Region),可以近似理解为一个围棋的棋盘。
  • G1收集器里面讲整个的内存区都混合在一起了,但其本身依然在小范围内要进行年轻代和老年代的区分,保留了新生代和老年代,但它们不再是物理隔离的,而是一部分Region的集合且不需要Region是连续的,也就是说依然会采用不同的GC方式来处理不同的区域。
  • G1虽然也是分代收集器,但整个内存分区不存在物理上的年轻代与老年代的区别,也不需要完全独立的survivor(to space)堆做复制准备。G1只有逻辑上的分代概念,或者说每个分区都可能随G1的运行在不同代之间前后切换。
G1底层原理

Region区域化垃圾收集器,最大好处是化整为零,打破了原来新生区和老年区的壁垒,避免了全内存扫描,只需要按照区域来进行扫描即可。

区域化内存划片Region,堆内存整体变为了一系列不连续的内存区域,避免了全内存区的GC操作。

核心思想是将整个堆内存区域分成大小相同的子区域(Region),在JVM启动时会自动设置这些子区域的大小。在堆的使用上,G1并不要求对象的存储一定是物理上连续的,只要逻辑上连续即可,每个分区也不会固定地为某个代服务,可以按需在年轻代和老年代之间切换。启动时可以通过参数-XX:G1HeapRegionSize=n可指定分区大小(1MB~32MB,且必须是2的幂),默认将整堆划分为2048个分区。

大小范围在1MB~32MB,最多能设置2048个区域,也即能够支持的最大内存为:32 M B ∗ 2048 = 65536 M B = 64 G

Region 区域化垃圾收集器

G1 将新生代、老年代的物理空间划分取消了,具体实现就是G1垃圾收集器将堆划分为若干个区域(Region)而不是一分为二为年轻代和老年代了,但是它仍然属于分代收集器,因为这些区域(Region)逻辑上还是分代的。

这些Region的一部分属于新生代,新生代的垃圾收集依然采用暂停所有应用线程的方式,将存活对象拷贝到老年代或者Survivor空间。还有一部分Region属于老年代,G1收集器通过将对象从一个区域复制到另外一个区域,完成了清理工作。这就意味着在正常的处理过程中,G1完成了堆的压缩(至少是部分堆的压缩),这样也就不会有CMS内存碎片问题的存在了。

在G1中,还有一种特殊的区域,叫Humongous区域。

如果一个对象占用的空间超过了分区容量50%以上,G1收集器就认为这是一个巨型对象。这些巨型对象默认直接会被分配在年老代,但是如果它是一个短期存在的巨型对象,就会对垃圾收集器造成负面影响。为了解决这个问题,G1划分了一个Humongous区,它用来专门存放巨型对象。如果一个H区装不下一个巨型对象,那么G1会寻找连续的H分区来存储。为了能找到连续的H区,有时候不得不启动Full GC。

回收步骤

G1收集器下的Young GC:针对Eden区进行收集,Eden区耗尽后会被触发,主要是小区域收集+形成连续的内存块,避免内存碎片

  • Eden区的数据移动到Survivor区,假如出现Survivor区空间不够,Eden区数据会部会晋升到Old区。

  • Survivor区的数据移动到新的Survivor区,部会数据晋升到Old区。

  • 最后Eden区收拾干净了,GC结束,用户的应用程序继续执行。

回收开始时
image-20221019130137936

回收完成后

image-20221019130156454

小区域收集 + 形成连续的内存块,最后在收集完成后,就会形成连续的内存空间,
这样就解决了内存碎片的问题

4步过程:
  • 初始标记:只标记GC Roots能直接关联到的对象
  • 并发标记:进行GC Roots Tracing的过程
  • 最终标记:修正并发标记期间,因程序运行导致标记发生变化的那一部分对象
  • 筛选回收:根据时间来进行价值最大化的回收

image-20221019130351087

G1参数配置
  • -XX:+UseG1GC:使用G1垃圾收集器
  • -XX:G1HeapRegionSize=n:设置的G1区域的大小。值是2的幂,范围是1MB到32MB。目标是根据最小的Java堆大小划分出约2048个区域。
  • -XX:MaxGCPauseMillis=n:最大GC停顿时间,这是个软目标,JVM将尽可能(但不保证)停顿小于这个时间。
  • -XX:InitiatingHeapOccupancyPercent=n:堆占用了多少的时候就触发GC,默认为45。
  • -XX:ConcGCThreads=n:并发GC使用的线程数。
  • -XX:G1ReservePercent=n:设置作为空闲空间的预留内存百分比,以降低目标空间溢出的风险,默认值是10%。

开发人员仅仅需要声明以下参数即可:

**三步归纳:**使用G1+设置最大内存+设置最大停顿时间

-XX:+UseG1GC
-Xmx32g
-XX:MaxGCPauseMillis=100
G1和CMS比较
  • G1不会产生内碎片

  • 是可以精准控制停顿。该收集器是把整个堆(新生代、老年代)划分成多个固定大小的区域,每次根据允许停顿的时间去收集垃圾最多的区域。

JVMGC结合SpringBoot微服务优化简介

启动微服务时候,就可以带上 JVM 和 GC 的参数进行调优。

  • IDEA开发微服务工程。
  • Maven进行clean package。
  • 要求微服务启动的时候,同时配置我们的JVM/GC的调优参数。
    公式:java -server jvm的各种参数 -jar 第1步上面的jar/war包名。

Linux性能检测

命令集合

top: 主要用于整机性能查看,命令结果如下图

image-20221019144523385

使用 top 命令的话,主要看load average,%CPU,%MEM三部分

  • load average表示系统负载,即任务队列的平均长度。 三个数值分别为1分钟、5分钟、15分钟前到现在的平均任务队列长度即系统负载情况。如果这个数除以逻辑CPU的数量,结果高于5的时候就表明系统在超负荷运转了。

在这个命令下,按数字1键的话,可以看到每个 CPU 的占用情况。

uptime命令是对top系统性能查看命令的精简版,只显示load average。

vmstat: 这个命令用于查看 CPU的性能但是又不仅限于CPU。一般vmstat工具的使用是通过两个数字参数来完成的,第一个参数是采样的时间间隔数(单位秒),第二个参数是采样的次数。下面我们展示vmstat -n 2 3命令的结果

image-20221019150346815

  • procs

    • r:运行和等待的CPU时间片的进程数。原则上1核的CPU的运行队列不要超过2(整个系统的运行队列不超过总核数的2倍,否则代表系统压力过大),这里我的虚拟机没有跑什么东西所以要么1要么0基本无压力

    • b:等待资源的进程数,比如正在等待磁盘I/O、网络I/O等

  • cpu

    • us:用户进程消耗CPU时间百分比,us值高,用户进程消耗CPU时间多,如果长期大于50%,需要优化程序

    • sy:内核进程消耗的CPU时间百分比即系统消耗时间占比
      us + sy 参考值为80%,如果us + sy 大于80%,说明可能存在CPU不足。

    • id:处于空闲的CPU百分比

    • wa:系统等待IO的CPU时间百分比

    • st:来自于一个虚拟机偷取的CPU时间比

mpstat: 该命令主要用于查看所有cpu核信息。下图为mpstat -P ALL 2命令执行结果

-P参数的意思是我们可以指定具体的CPU进行查看(默认CPU下标从0开始),后面的2代表每两秒查看一次。

image-20221019151621367

pidstat: 查看每个进程使用cpu的用量分解信息,结果如下:

该命令默认查看当前时间所有线程的信息。我们可以用参数-u指定打印信息的时间间隔(秒为单位),还可以用-p指令指定要查看的线程(后跟线程id)。

image-20221019151847424

free: 该命令用于查看应用程序可用内存,其后跟参数-h表示以人类能看懂的方式查看物理内存。-k/m/g分别表示以KB,MB,GB形式进行展示。

image-20221019153133521

经验值:

  • 应用程序可用内存l系统物理内存>70%内存充足

  • 应用程序可用内存/系统物理内存<20%内存不足,需要增加内存

  • 20%<应用程序可用内存/系统物理内存<70%内存基本够用

df: 查看磁盘的剩余空间

image-20221019153200892

iostat: 磁盘I/O性能评估(系统慢有两种原因引起的:一个是CPU占用高,一个是大量的IO操作)。下图为iostat -xdk 2 3命令执行结果。2表示查询时间间隔为2s,3表示查询次数

image-20221019153652940

磁盘块设备分布

  • rkB/s每秒读取数据量kB;wkB/s每秒写入数据量kB;
  • svctm lO请求的平均服务时间,单位毫秒;
  • await l/O请求的平均等待时间,单位毫秒;值越小,性能越好;
  • util一秒中有百分几的时间用于I/O操作。接近100%时,表示磁盘带宽跑满,需要优化程序或者增加磁盘;
  • rkB/s、wkB/s根据系统应用不同会有不同的值,但有规律遵循:长期、超大数据读写,肯定不正常,需要优化程序读取。
  • svctm的值与await的值很接近,表示几乎没有IO等待,磁盘性能好。
  • 如果await的值远高于svctm的值,则表示IO队列等待太长,需要优化程序或更换更快磁盘。

ifstat: 查看网络IO情况
image-20221019154136600

可以看到各个网卡的in、out情况,观察网络负载情况程序,网络读写是否正常

CPU占用过高的定位分析思路

单一通过JDK和Linux我们都无法分析,需要结合Linux和JDK命令一块分析。

案例步骤
  1. 先用top命令找出CPU占比最高的

    image-20221019161932762

  2. ps -ef命令或者jps命令(因为我们知道了是一个Java程序CPU占比比较高)进行进一步定位,看看是哪一个后台程序出的问题(其实上一步中就已经有进程id了)。这一步的目的就是查出出问题的程序进程id的。

  3. 定位到具体线程或者代码(根据上面查到的进程pid)

    • ps -mp 进程id -o THREAD,tid,time
      • -m 显示所有的线程
        -p pid进程使用cpu的时间
        -o 该参数后是用户自定义格式

    image-20221019162118561

  4. 将需要的线程TID转换为16进制格式(英文小写格式),命令printf %x 172即可将172转换为十六进制

  5. jstack 进程ID | grep tid 16进制线程ID小写英文 -A60打印从头开始的60行,这一步就可以精准定位到错误的地方(哪一行代码)
    然后在结果中找我们的包名再找类再找代码就可以了。

GitHub

在我们日常学习工作中,难免要使用到GitHub。这个节就教大家如何使用Github去看一些优秀的框架,代码来提升自己。

GitHub常用词

  • watch:表示关注这个项目,之后会持续收到该项目的动态

  • fork:复制项目到自己的Github仓库中

  • star:可以理解为点赞

  • clone:将项目下载至本地

  • follow,关注你感兴趣的作者,会收到关注作者的动态

in关键词限制搜索范围:

公式 :xxx(关键词) in:name或description或readme
xxx in:name 项目名包含xxx的
xxx in:description 项目描述包含xxx的
xxx in:readme 项目的readme文件中包含xxx的组合使用
组合使用:
搜索项目名或者readme中包含xxx的项目
xxx in:name,readme

star和fork数量关键字进行范围搜索

公式:xxx关键字 stars 通配符 :> 或者 :>=
区间范围数字: stars:数字1…数字2
案例
查找stars数大于等于5000的springboot项目:springboot stars:>=5000
查找forks数在1000~2000之间的springboot项目:springboot forks:1000…5000
组合使用
查找star大于1000以及fork数在500到1000的springboot项目:springboot stars:>1000 forks:500…1000

awesome搜索

公式:awesome 关键字。awesome系列,一般用来收集学习、工具、书籍类相关的项目
搜索优秀的redis相关的项目,包括框架,教程等就可以用awesome redis

高亮显示某行代码

#L数字
高亮显示一行:地址后面紧跟 #L10
https://github.com/xjxc/gulimall/pom.xml#L13
高亮显示多行:地址后面紧跟 #Lx - #Ln
https://github.com/xjx/gulimall/pom.xml#L13-L30

GitHub快捷键

T搜索
在项目仓库下按键盘t即可进行项目内搜索

更多github快捷键

搜索区域活跃用户

location:地区
language:语言
例如:location:beijing language:java


面试题第二季结束咯!感谢耐心看到这里的同学,觉得文章对您有帮助的话希望同学们不要吝啬您手中的赞,动动您智慧的小手,您的认可就是我创作的动力!
之后还会勤更自己的学习笔记,不久就会更新第三季面试题,感兴趣的同学点点关注哦。

评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值