Java 面试

本文最后更新于:6 个月前

第一部分 Java 并发编程

第1章 谈谈Volatile

1 Volatile和JMM内存模型的可见性

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

Volatile在日常的单线程环境是应用不到的

  • Volatile是Java虚拟机提供的轻量级的同步机制(三大特性)
    • 保证可见性
    • 不保证原子性
    • 禁止指令重排

1.1 谈谈对Volatile的理解

1.2 JMM是什么

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

JMM关于同步的规定:

  • 线程解锁前,必须把共享变量的值刷新回主内存
  • 线程加锁前,必须读取主内存的最新值,到自己的工作内存
  • 加锁和解锁是同一把锁

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

image-20200309153225758

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

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

  • 主内存:就是计算机的内存,也就是经常提到的8G内存,16G内存

  • 工作内存:但我们实例化 new student,那么 age = 25 也是存储在主内存中

    • 当同时有三个线程同时访问 student中的age变量时,那么每个线程都会拷贝一份,到各自的工作内存,从而实现了变量的拷贝

    image-20200309154435933

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

1.3 缓存一致性

为什么这里主线程中某个值被更改后,其它线程能马上知晓呢?其实这里是用到了总线嗅探技术

在说嗅探技术之前,首先谈谈缓存一致性的问题,就是当多个处理器运算任务都涉及到同一块主内存区域的时候,将可能导致各自的缓存数据不一。

为了解决缓存一致性的问题,需要各个处理器访问缓存时都遵循一些协议,在读写时要根据协议进行操作,这类协议主要有MSI、MESI等等。

1 MESI

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

2 总线嗅探

那么是如何发现数据是否失效呢?

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

3 总线风暴

总线嗅探技术有哪些缺点?

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

1.4 JMM的特性

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

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

1.5 可见性代码验证

但我们对于成员变量没有添加任何修饰时,是无法感知其它线程修改后的值

package com.moxi.interview.study.thread;

/**
 * Volatile Java虚拟机提供的轻量级同步机制
 *
 * 可见性(及时通知)
 * 不保证原子性
 * 禁止指令重排
 */

import java.util.concurrent.TimeUnit;

/**
 * 假设是主物理内存
 */
class MyData {

    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();

        while(myData.number == 0) {
            // main线程就一直在这里等待循环,直到number的值不等于零
        }

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

        /**
         * 最后输出结果:
         * AAA	 come in
         * AAA	 update number value:60
         * 最后线程没有停止,并行没有输出  mission is over 这句话,说明没有用volatile修饰的变量,是没有可见性
         */

    }
}

输出结果为

image-20200309162154191

最后线程没有停止,并行没有输出 mission is over 这句话,说明没有用volatile修饰的变量,是没有可见性

当我们修改MyData类中的成员变量时,并且添加volatile关键字修饰

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

    public void addTo60() {
        this.number = 60;
    }
}

最后输出的结果为:

image-20200309162154191

主线程也执行完毕了,说明volatile修饰的变量,是具备JVM轻量级同步机制的,能够感知其它线程的修改后的值。

2 Volatile不保证原子性

2.1 前言

通过前面对JMM的介绍,我们知道,各个线程对主内存中共享变量的操作都是各个线程各自拷贝到自己的工作内存进行操作后在写回到主内存中的。

这就可能存在一个线程AAA修改了共享变量X的值,但是还未写入主内存时,另外一个线程BBB又对主内存中同一共享变量X进行操作,但此时A线程工作内存中共享变量X对线程B来说是不可见,这种工作内存与主内存同步延迟现象就造成了可见性问题。

2.2 原子性

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

数据库也经常提到事务具备原子性

2.3 代码测试

为了测试volatile是否保证原子性,我们创建了20个线程,然后每个线程分别循环1000次,来调用number++的方法

MyData myData = new MyData();

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

最后通过 Thread.activeCount(),来感知20个线程是否执行完毕,这里判断线程数是否大于2,为什么是2?因为默认是有两个线程的,一个main线程,一个gc线程

// 需要等待上面20个线程都计算完成后,在用main线程取得最终的结果值
while(Thread.activeCount() > 2) {
    // yield表示不执行
    Thread.yield();
}

然后在线程执行完毕后,我们在查看number的值,假设volatile保证原子性的话,那么最后输出的值应该是

20 * 1000 = 20000,

完整代码如下所示:


/**
 * Volatile Java虚拟机提供的轻量级同步机制
 *
 * 可见性(及时通知)
 * 不保证原子性
 * 禁止指令重排
 */

import java.util.concurrent.TimeUnit;

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

    public void addTo60() {
        this.number = 60;
    }

    /**
     * 注意,此时number 前面是加了volatile修饰
     */
    public void addPlusPlus() {
        number ++;
    }
}

/**
 * 验证volatile的可见性
 * 1、 假设int number = 0, number变量之前没有添加volatile关键字修饰
 * 2、添加了volatile,可以解决可见性问题
 *
 * 验证volatile不保证原子性
 * 1、原子性指的是什么意思?
 */
public class VolatileDemo {

    public static void main(String args []) {

        MyData myData = new MyData();

        // 创建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);

    }
}

最终结果我们会发现,number输出的值并没有20000,而且是每次运行的结果都不一致的,这说明了volatile修饰的变量不保证原子性

第一次:

image-20200309172900462

第二次:

image-20200309172919295

第三次:

image-20200309172929820

2.4 为什么出现数值丢失

image-20200309174220675

各自线程在写入主内存的时候,出现了数据的丢失,而引起的数值缺失的问题

下面我们将一个简单的number++操作,转换为字节码文件一探究竟

public class T1 {
    volatile int n = 0;
    public void add() {
        n++;
    }
}

转换后的字节码文件

public class com.moxi.interview.study.thread.T1 {
  volatile int n;

  public com.moxi.interview.study.thread.T1();
    Code:
       0: aload_0
       1: invokespecial #1                  // Method java/lang/Object."<init>":()V
       4: aload_0
       5: iconst_0
       6: putfield      #2                  // Field n:I
       9: return

  public void add();
    Code:
       0: aload_0
       1: dup
       2: getfield      #2                  // Field n:I
       5: iconst_1
       6: iadd
       7: putfield      #2                  // Field n:I
      10: return
}

这里查看字节码的操作,是用到了IDEA的javap命令

我们首先,使用IDEA提供的External Tools,来扩展javap命令

image-20200309183026329

完成上述操作后,我们在需要查看字节码的文件下,右键选择 External Tools即可

image-20200309183115613

如果出现了找不到指定类,那是因为我们创建的是spring boot的maven项目,我们之前需要执行mvn package命令,进行打包操作,将其编译成class文件

移动到底部,有一份字节码指令对照表,方便我们进行阅读

下面我们就针对 add() 这个方法的字节码文件进行分析

public void add();
  Code:
     0: aload_0
     1: dup
     2: getfield      #2    // Field n:I
     5: iconst_1
     6: iadd
     7: putfield      #2    // Field n:I
    10: return

我们能够发现 n++这条命令,被拆分成了3个指令

  • 执行getfield 从主内存拿到原始n
  • 执行iadd 进行加1操作
  • 执行putfileld 把累加后的值写回主内存

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

2.5 如何解决

因此这也说明,在多线程环境下 number ++ 在多线程环境下是非线程安全的,解决的方法有哪些呢?

  • 在方法上加入 synchronized
public synchronized void addPlusPlus() {
	number ++;
}

运行结果:

image-20200309173315294

我们能够发现引入synchronized关键字后,保证了该方法每次只能够一个线程进行访问和操作,最终输出的结果也就为20000

2.6 其它解决方法

上面的方法引入synchronized,虽然能够保证原子性,但是为了解决number++,而引入重量级的同步机制,有种 杀鸡焉用牛刀

除了引用synchronized关键字外,还可以使用JUC下面的原子包装类,即刚刚的int类型的number,可以使用AtomicInteger来代替

/**
     *  创建一个原子Integer包装类,默认为0
      */
AtomicInteger atomicInteger = new AtomicInteger();

public void addAtomic() {
    // 相当于 atomicInter ++
    atomicInteger.getAndIncrement();
}

然后同理,继续刚刚的操作

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

最后输出

// 假设volatile保证原子性,那么输出的值应该为:  20 * 1000 = 20000
System.out.println(Thread.currentThread().getName() + "\t finally number value: " + myData.number);
System.out.println(Thread.currentThread().getName() + "\t finally atomicNumber value: " + myData.atomicInteger);

下面的结果,一个是引入synchronized,一个是使用了原子包装类AtomicInteger

image-20200309205242622

3 Volatile禁止指令重排

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

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

单线程环境里面确保最终执行结果和代码顺序的结果一致

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

多线程环境中线程交替执行,由于编译器优化重排的存在,两个线程中使用的变量能否保证一致性是无法确定的,结果无法预测。

3.1 指令重排 - example 1

public void mySort() {
	int x = 11;
	int y = 12;
	x = x + 5;
	y = x * x;
}

按照正常单线程环境,执行顺序是 1 2 3 4

但是在多线程环境下,可能出现以下的顺序:

  • 2 1 3 4
  • 1 3 2 4

上述的过程就可以当做是指令的重排,即内部执行顺序,和我们的代码顺序不一样

但是指令重排也是有限制的,即不会出现下面的顺序

  • 4 3 2 1

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

因为步骤 4:需要依赖于 y的申明,以及x的申明,故因为存在数据依赖,无法首先执行

例子

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.2 指令重排 - example 2

比如下面这段代码

/**
 * ResortSeqDemo
 */
public class ResortSeqDemo {
    int a= 0;
    boolean flag = false;

    public void method01() {
        a = 1;
        flag = true;
    }

    public void method02() {
        if(flag) {
            a = a + 5;
            System.out.println("reValue:" + a);
        }
    }
}

我们按照正常的顺序,分别调用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来修饰,来保证线程安全性

3.3 Volatile针对指令重排做了啥

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

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

  • 保证特定操作的顺序
  • 保证某些变量的内存可见性(利用该特性实现volatile的内存可见性)

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

image-20200310162654437

也就是过在Volatile的写 和 读的时候,加入屏障,防止出现指令重排的

3.4 线程安全获得保证

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

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

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

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

3.5 总线嗅探

4 Volatile的应用

单例模式DCL代码

首先回顾一下,单线程下的单例模式代码

/**
 * SingletonDemo(单例模式)
 */
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());
    }
}

最后输出的结果

image-20200310164513408

但是在多线程的环境下,我们的单例模式是否还是同一个对象了

/**
 * SingletonDemo(单例模式)
 */
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();
        }
    }
}

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

image-20200310164720940

1 解决方法1

引入synchronized关键字

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

输出结果

image-20200310164946940

我们能够发现,通过引入Synchronized关键字,能够解决高并发环境下的单例模式问题

但是synchronized属于重量级的同步机制,它只允许一个线程同时访问获取实例的方法,但是为了保证数据一致性,而减低了并发性,因此采用的比较少

2 解决方法2

通过引入DCL Double Check Lock 双端检锁机制

就是在进来和出去的时候,进行检测

public static SingletonDemo getInstance() {
    if(instance == null) {
        // 同步代码段的时候,进行检测
        synchronized (SingletonDemo.class) {
            if(instance == null) {
                instance = new SingletonDemo();
            }
        }
    }
    return instance;
}

最后输出的结果为:

image-20200310165703190

从输出结果来看,确实能够保证单例模式的正确性,但是上面的方法还是存在问题的

DCL(双端检锁)机制不一定是线程安全的,原因是有指令重排的存在,加入volatile可以禁止指令重排

原因是在某一个线程执行到第一次检测的时候,读取到 instance 不为null,instance的引用对象可能没有完成实例化。因为 instance = new SingletonDemo();可以分为以下三步进行完成:

  • 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、初始化对象

这样就会造成什么问题呢?

也就是当我们执行到重排后的步骤2,试图获取instance的时候,会得到null,因为对象的初始化还没有完成,而是在重排后的步骤3才完成,因此执行单例模式的代码时候,就会重新在创建一个instance实例

指令重排只会保证串行语义的执行一致性(单线程),但并不会关系多线程间的语义一致性

所以当一条线程访问instance不为null时,由于instance实例未必已初始化完成,这就造成了线程安全的问题

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

private static volatile SingletonDemo instance = null;

3 最终代码

/**
 * SingletonDemo(单例模式)
 */
public class SingletonDemo {

    private static volatile SingletonDemo instance = null;

    private SingletonDemo () {
        System.out.println(Thread.currentThread().getName() + "\t 我是构造方法SingletonDemo");
    }

    public static SingletonDemo getInstance() {
        if(instance == null) {
            // a 双重检查加锁多线程情况下会出现某个线程虽然这里已经为空,但是另外一个线程已经执行到d处
            synchronized (SingletonDemo.class) //b
            { 
           //c不加volitale关键字的话有可能会出现尚未完全初始化就获取到的情况。原因是内存模型允许无序写入
                if(instance == null) { 
                	// d 此时才开始初始化
                    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());

        for (int i = 0; i < 10; i++) {
            new Thread(() -> {
                SingletonDemo.getInstance();
            }, String.valueOf(i)).start();
        }
    }
}

第2章 CAS底层原理

1 概念

CAS的全称是Compare-And-Swap,它是CPU并发原语

它的功能是判断内存某个位置的值是否为预期值,如果是则更改为新的值,这个过程是原子的

CAS并发原语体现在Java语言中就是sun.misc.Unsafe类的各个方法。调用UnSafe类中的CAS方法,JVM会帮我们实现出CAS汇编指令,这是一种完全依赖于硬件的功能,通过它实现了原子操作,再次强调,由于CAS是一种系统原语,原语属于操作系统用于范畴,是由若干条指令组成,用于完成某个功能的一个过程,并且原语的执行必须是连续的,在执行过程中不允许被中断,也就是说CAS是一条CPU的原子指令,不会造成所谓的数据不一致的问题,也就是说CAS是线程安全的。

2 代码使用

首先调用AtomicInteger创建了一个实例, 并初始化为5

// 创建一个原子类
AtomicInteger atomicInteger = new AtomicInteger(5);

然后调用CAS方法,企图更新成2019,这里有两个参数,一个是5,表示期望值,第二个就是我们要更新的值

atomicInteger.compareAndSet(5, 2019)

然后再次使用了一个方法,同样将值改成1024

atomicInteger.compareAndSet(5, 1024)

完整代码如下:

/**
 * CASDemo
 *
 * 比较并交换:compareAndSet
 */
public class CASDemo {
    public static void main(String[] args) {
        // 创建一个原子类
        AtomicInteger atomicInteger = new AtomicInteger(5);

        /**
         * 一个是期望值,一个是更新值,但期望值和原来的值相同时,才能够更改
         * 假设三秒前,我拿的是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());
    }
}

上面代码的执行结果为

image-20200310201327734

这是因为我们执行第一个的时候,期望值和原本值是满足的,因此修改成功,但是第二次后,主内存的值已经修改成了2019,不满足期望值,因此返回了false,本次写入失败

image-20200310201311367

这个就类似于SVN或者Git的版本号,如果没有人更改过,就能够正常提交,否者需要先将代码pull下来,合并代码后,然后提交

3 CAS底层原理

首先我们先看看 atomicInteger.getAndIncrement()方法的源码

image-20200310203030720

从这里能够看到,底层又调用了一个unsafe类的getAndAddInt方法

3.1 unsafe类

image-20200310203350122

Unsafe是CAS的核心类,由于Java方法无法直接访问底层系统,需要通过本地(Native)方法来访问,Unsafe相当于一个后门,基于该类可以直接操作特定的内存数据。Unsafe类存在sun.misc包中,其内部方法操作可以像C的指针一样直接操作内存,因为Java中的CAS操作的执行依赖于Unsafe类的方法。

注意Unsafe类的所有方法都是native修饰的,也就是说unsafe类中的方法都直接调用操作系统底层资源执行相应的任务

为什么Atomic修饰的包装类,能够保证原子性,依靠的就是底层的unsafe类

3.2 变量valueOffset

表示该变量值在内存中的偏移地址,因为Unsafe就是根据内存偏移地址获取数据的。

image-20200310203030720

从这里我们能够看到,通过valueOffset,直接通过内存地址,获取到值,然后进行加1的操作

3.3 变量value用volatile修饰

保证了多线程之间的内存可见性

image-20200310210701761

var5:就是我们从主内存中拷贝到工作内存中的值(每次都要从主内存拿到最新的值到自己的本地内存,然后执行compareAndSwapInt()在再和主内存的值进行比较。因为线程不可以直接越过高速缓存,直接操作主内存,所以执行上述方法需要比较一次,在执行加1操作)

那么操作的时候,需要比较工作内存中的值,和主内存中的值进行比较

假设执行 compareAndSwapInt返回false,那么就一直执行 while方法,直到期望的值和真实值一样

  • val1:AtomicInteger对象本身
  • var2:该对象值得引用地址
  • var4:需要变动的数量
  • var5:用var1和var2找到的内存中的真实值
    • 用该对象当前的值与var5比较
    • 如果相同,更新var5 + var4 并返回true
    • 如果不同,继续取值然后再比较,直到更新完成

这里没有用synchronized,而用CAS,这样提高了并发性,也能够实现一致性,是因为每个线程进来后,进入的do while循环,然后不断的获取内存中的值,判断是否为最新,然后在进行更新操作。

假设线程A和线程B同时执行getAndInt操作(分别跑在不同的CPU上)

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

Unsafe类 + CAS思想: 也就是自旋,自我旋转

4 底层汇编

Unsafe类中的compareAndSwapInt是一个本地方法,该方法的实现位于unsafe.cpp中

  • 先想办法拿到变量value在内存中的地址
  • 通过Atomic::cmpxchg实现比较替换,其中参数X是即将更新的值,参数e是原内存的值

5 CAS缺点

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

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

6 ABA问题

7 总结

7.1 CAS

CAS是compareAndSwap,比较当前工作内存中的值和主物理内存中的值,如果相同则执行规定操作,否者继续比较直到主内存和工作内存的值一致为止

7.2 CAS应用

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

第3章 原子类AtomicInteger的ABA问题

1 连环套路

从AtomicInteger引出下面的问题

CAS -> Unsafe -> CAS底层思想 -> ABA -> 原子引用更新 -> 如何规避ABA问题

2 ABA问题是什么

狸猫换太子

image-20200311212442057

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

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

3 CAS导致ABA问题

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

比如说一个线程one从内存位置V中取出A,这时候另外一个线程two也从内存中取出A,并且线程two进行了一些操作将值变成了B,然后线程two又将V位置的数据变成A,这时候线程one进行CAS操作发现内存中仍然是A,然后线程one操作成功

尽管线程one的CAS操作成功,但是不代表这个过程就是没有问题的

4 ABA问题

CAS只管开头和结尾,也就是头和尾是一样,那就修改成功,中间的这个过程,可能会被人修改过

5 原子引用

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

/**
 * 原子引用
 */

class User {
    String userName;
    int age;

    public User(String userName, int age) {
        this.userName = userName;
        this.age = age;
    }

    public String getUserName() {
        return userName;
    }

    public void setUserName(String userName) {
        this.userName = userName;
    }

    public int getAge() {
        return age;
    }

    public void setAge(int age) {
        this.age = age;
    }

    @Override
    public String toString() {
        return "User{" +
                "userName='" + userName + '\'' +
                ", age=" + age +
                '}';
    }
}
public class AtomicReferenceDemo {

    public static void main(String[] args) {

        User z3 = new User("z3", 22);

        User l4 = new User("l4", 25);

        // 创建原子引用包装类
        AtomicReference<User> atomicReference = new AtomicReference<>();

        // 现在主物理内存的共享变量,为z3
        atomicReference.set(z3);

        // 比较并交换,如果现在主物理内存的值为z3,那么交换成l4
        System.out.println(atomicReference.compareAndSet(z3, l4) + "\t " + atomicReference.get().toString());

        // 比较并交换,现在主物理内存的值是l4了,但是预期为z3,因此交换失败
        System.out.println(atomicReference.compareAndSet(z3, l4) + "\t " + atomicReference.get().toString());
    }
}

5.1 基于原子引用的ABA问题

我们首先创建了两个线程,然后T1线程,执行一次ABA的操作,T2线程在一秒后修改主内存的值

/**
 * ABA问题的解决,AtomicStampedReference
 */
public class ABADemo {

    /**
     * 普通的原子引用包装类
     */
    static AtomicReference<Integer> atomicReference = new AtomicReference<>(100);

    public static void main(String[] args) {

        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();
    }
}

我们发现,它能够成功的修改,这就是ABA问题

image-20200312154752973

6 解决ABA问题

新增一种机制,也就是修改版本号,类似于时间戳的概念

T1: 100 1 2019 2

T2: 100 1 101 2 100 3

如果T1修改的时候,版本号为2,落后于现在的版本号3,所以要重新获取最新值,这里就提出了一个使用时间戳版本号,来解决ABA问题的思路

7 AtomicStampedReference

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

/**
 * ABA问题的解决,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();

        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();

    }
}

运行结果为:

image-20200312200434776

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

8 LongAdder(CAS机制优化)

LongAdder是java8为我们提供的新的类,跟AtomicLong有相同的效果。是对CAS机制的优化

LongAdder:
//变量声明
public static LongAdder count = new LongAdder();
//变量操作
count.increment();
//变量取值
count

为什么有了AtomicLong还要新增一个LongAdder呢

原因是:CAS底层实现是在一个死循环中不断地尝试修改目标值,直到修改成功。如果竞争不激烈的时候,修改成功率很高,否则失败率很高。在失败的时候,这些重复的原子性操作会耗费性能。(不停的自旋,进入一个无限重复的循环中)

image-20200429085540554

核心思想:将热点数据分离。

比如说它可以将AtomicLong内部的内部核心数据value分离成一个数组,每个线程访问时,通过hash等算法映射到其中一个数字进行计数,而最终的计数结果则为这个数组的求和累加,其中热点数据value会被分离成多个单元的cell,每个cell独自维护内部的值。当前对象的实际值由所有的cell累计合成,这样热点就进行了有效地分离,并提高了并行度。这相当于将AtomicLong的单点的更新压力分担到各个节点上。在低并发的时候通过对base的直接更新,可以保障和AtomicLong的性能基本一致。而在高并发的时候通过分散提高了性能。

public void increment() {
    add(1L);
}
public void add(long x) {
    Cell[] as; long b, v; int m; Cell a;
    if ((as = cells) != null || !casBase(b = base, b + x)) {
        boolean uncontended = true;
        if (as == null || (m = as.length - 1) < 0 ||
            (a = as[getProbe() & m]) == null ||
            !(uncontended = a.cas(v = a.value, v + x)))
            longAccumulate(x, null, uncontended);
    }
}

但是这个CAS有没有问题呢?肯定是有的。比如说大量的线程同时并发修改一个AtomicInteger,可能有很多线程会不停的自旋,进入一个无限重复的循环中。

这些线程不停地获取值,然后发起CAS操作,但是发现这个值被别人改过了,于是再次进入下一个循环,获取值,发起CAS操作又失败了,再次进入下一个循环。

在大量线程高并发更新AtomicInteger的时候,这种问题可能会比较明显,导致大量线程空循环,自旋转,性能和效率都不是特别好。

于是,当当当当,Java 8推出了一个新的类,LongAdder,他就是尝试使用分段CAS以及自动分段迁移的方式来大幅度提升多线程高并发执行CAS操作的性能!

image-20200429085141487

在LongAdder的底层实现中,首先有一个base值,刚开始多线程来不停的累加数值,都是对base进行累加的,比如刚开始累加成了base = 5。

接着如果发现并发更新的线程数量过多,在发生竞争的情况下,会有一个Cell数组用于将不同线程的操作离散到不同的节点上去 ==(会根据需要扩容,最大为CPU核)==就会开始施行分段CAS的机制,也就是内部会搞一个Cell数组,每个数组是一个数值分段。

这时,让大量的线程分别去对不同Cell内部的value值进行CAS累加操作,这样就把CAS计算压力分散到了不同的Cell分段数值中了!

这样就可以大幅度的降低多线程并发更新同一个数值时出现的无限循环的问题,大幅度提升了多线程并发更新数值的性能和效率!

而且他内部实现了自动分段迁移的机制,也就是如果某个Cell的value执行CAS失败了,那么就会自动去找另外一个Cell分段内的value值进行CAS操作。

这样也解决了线程空旋转、自旋不停等待执行CAS操作的问题,让一个线程过来执行CAS时可以尽快的完成这个操作。

最后,如果你要从LongAdder中获取当前累加的总值,就会把base值和所有Cell分段数值加起来返回给你。

image-20200429085957778

如上图所示,LongAdder则是内部维护多个Cell变量,每个Cell里面有一个初始值为0的long型变量,在同等并发量的情况下,争夺单个变量的线程会减少,这是变相的减少了争夺共享资源的并发量,另外多个线程在争夺同一个原子变量时候,

如果失败并不是自旋CAS重试,而是尝试获取其他原子变量的锁,最后当获取当前值时候是把所有变量的值累加后再加上base的值返回的。

LongAdder维护了要给延迟初始化的原子性更新数组和一个基值变量base数组的大小保持是2的N次方大小,数组表的下标使用每个线程的hashcode值的掩码表示,数组里面的变量实体是Cell类型。

Cell 类型是Atomic的一个改进,用来减少缓存的争用,对于大多数原子操作字节填充是浪费的,因为原子操作都是无规律的分散在内存中进行的,多个原子性操作彼此之间是没有接触的,但是原子性数组元素彼此相邻存放将能经常共享缓存行,也就是伪共享。所以这在性能上是一个提升。(补充:可以看到Cell类用Contended注解修饰,这里主要是解决false sharing(伪共享的问题),不过个人认为伪共享翻译的不是很好,或者应该是错误的共享,比如两个volatile变量被分配到了同一个缓存行,但是这两个的更新在高并发下会竞争,比如线程A去更新变量a,线程B去更新变量b,但是这两个变量被分配到了同一个缓存行,因此会造成每个线程都去争抢缓存行的所有权,例如A获取了所有权然后执行更新这时由于volatile的语义会造成其刷新到主存,但是由于变量b也被缓存到同一个缓存行,因此就会造成cache miss,这样就会造成极大的性能损失)
LongAdder的add操作图

image-20200429090249633

可以看到,只有从未出现过并发冲突的时候,base基数才会使用到,一旦出现了并发冲突,之后所有的操作都只针对Cell[]数组中的单元Cell。
如果Cell[]数组未初始化,会调用父类的longAccumelate去初始化Cell[],如果Cell[]已经初始化但是冲突发生在Cell单元内,则也调用父类的longAccumelate,此时可能就需要对Cell[]扩容了。
另外由于Cells占用内存是相对比较大的,所以一开始并不创建,而是在需要时候再创建,也就是惰性加载,当一开始没有空间时候,所有的更新都是操作base变量。

image-20200429090556928

如上图代码:
例如32、64位操作系统的缓存行大小不一样,因此JAVA8中就增加了一个注@sun.misc.Contended解用于解决这个问题,由JVM去插入这些变量,具体可以参考openjdk.java.net/jeps/142 ,但是通常来说对象是不规则的分配到内存中的,但是数组由于是连续的内存,因此可能会共享缓存行,因此这里加一个Contended注解以防cells数组发生伪共享的情况。

为了降低高并发下多线程对一个变量CAS争夺失败后大量线程会自旋而造成降低并发性能问题,LongAdder内部通过根据并发请求量来维护多个Cell元素(一个动态的Cell数组)来分担对单个变量进行争夺资源。

image-20200429090713078

可以看到LongAdder继承自Striped64类,Striped64内部维护着三个变量,LongAdder的真实值其实就是base的值与Cell数组里面所有Cell元素值的累加,base是个基础值,默认是0,cellBusy用来实现自旋锁,当创建Cell元素或者扩容Cell数组时候用来进行线程间的同步。

在无竞争下直接更新base,类似AtomicLong高并发下,会将每个线程的操作hash到不同的cells数组中,从而将AtomicLong中更新一个value的行为优化之后,分散到多个value中
从而降低更新热点,而需要得到当前值的时候,直接 将所有cell中的value与base相加即可,但是跟AtomicLong(compare and change -> xadd)的CAS不同,incrementAndGet操作及其变种可以返回更新后的值,而LongAdder返回的是void。

由于Cell相对来说比较占内存,因此这里采用懒加载的方式,在无竞争的情况下直接更新base域,在第一次发生竞争的时候(CAS失败)就会创建一个大小为2的cells数组,每次扩容都是加倍,只到达到CPU核数。同时我们知道扩容数组等行为需要只能有一个线程同时执行,因此需要一个锁,这里通过CAS更新cellsBusy来实现一个简单的spin lock。

数组访问索引是通过Thread里的threadLocalRandomProbe域取模实现的,这个域是ThreadLocalRandom更新的,cells的数组大小被限制为CPU的核数,因为即使有超过核数个线程去更新,但是每个线程也只会和一个CPU绑定,更新的时候顶多会有cpu核数个线程,因此我们只需要通过hash将不同线程的更新行为离散到不同的slot即可。
我们知道线程、线程池会被关闭或销毁,这个时候可能这个线程之前占用的slot就会变成没人用的,但我们也不能清除掉,因为一般web应用都是长时间运行的,线程通常也会动态创建、销毁,很可能一段时间后又会被其他线程占用,而对于短时间运行的,例如单元测试,清除掉有啥意义呢?

第4章 Collection线程不安全的举例

1 前言

1、当我们执行下面语句的时候,底层进行了什么操作

new ArrayList<Integer>();

底层创建了一个空的数组,伴随着初始值为10

当执行add方法后,如果超过了10,那么会进行扩容,扩容的大小为原值的一半,也就是5个,使用下列方法扩容

Arrays.copyOf(elementData, netCapacity)

2 单线程环境下

单线程环境的ArrayList是不会有问题的

public class ArrayListNotSafeDemo {
    public static void main(String[] args) {

        List<String> list = new ArrayList<>();
        list.add("a");
        list.add("b");
        list.add("c");

        for(String element : list) {
            System.out.println(element);
        }
    }
}

3 多线程环境

为什么ArrayList是线程不安全的?因为在进行写操作的时候,方法上为了保证并发性,是没有添加synchronized修饰,所以并发写的时候,就会出现问题

image-20200312202720715

当我们同时启动30个线程去操作List的时候

/**
 * 集合类线程不安全举例
 */
public class ArrayListNotSafeDemo {
    public static void main(String[] args) {

        List<String> list = 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

image-20200312205142763

这个异常是 并发修改的异常

5 解决方案

5.1 方案一:Vector

第一种方法,就是不用ArrayList这种不安全的List实现类,而采用Vector,线程安全的

关于Vector如何实现线程安全的,而是在方法上加了锁,即synchronized

image-20200312210401865

这样就每次只能够一个线程进行操作,所以不会出现线程不安全的问题,但是因为加锁了,导致并发性基于下降

5.2 方案二:Collections.synchronized()

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

采用Collections集合工具类,在ArrayList外面包装一层 同步 机制

5.3 方案三:采用JUC里面的方法

CopyOnWriteArrayList:写时复制,主要是一种读写分离的思想

写时复制,CopyOnWrite容器即写时复制的容器,往一个容器中添加元素的时候,不直接往当前容器Object[]添加,而是先将Object[]进行copy,复制出一个新的容器object[] newElements,然后新的容器Object[] newElements里添加原始,添加元素完后,在将原容器的引用指向新的容器 setArray(newElements);这样做的好处是可以对copyOnWrite容器进行并发的读 ,而不需要加锁,因为当前容器不需要添加任何元素。所以CopyOnWrite容器也是一种读写分离的思想,读和写不同的容器

就是写的时候,把ArrayList扩容一个出来,然后把值填写上去,在通知其他的线程,ArrayList的引用指向扩容后的

查看底层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;
        setArray(newElements);
        return true;
    } finally {
        lock.unlock();
    }
}

首先需要加锁

final ReentrantLock lock = this.lock;
lock.lock();

然后在末尾扩容一个单位

Object[] elements = getArray();
int len = elements.length;
Object[] newElements = Arrays.copyOf(elements, len + 1);

然后在把扩容后的空间,填写上需要add的内容

newElements[len] = e;

最后把内容set到Array中

6 HashSet线程不安全

CopyOnWriteArraySet

底层还是使用CopyOnWriteArrayList进行实例化

image-20200312221602095

HashSet底层结构

同理HashSet的底层结构就是HashMap

image-20200312221735178

但是为什么我调用 HashSet.add()的方法,只需要传递一个元素,而HashMap是需要传递key-value键值对?

首先我们查看hashSet的add方法

public boolean add(E e) {
	return map.put(e, PRESENT)==null;
}

我们能发现但我们调用add的时候,存储一个值进入map中,只是作为key进行存储,而value存储的是一个Object类型的常量,也就是说HashSet只关心key,而不关心value

7 HashMap线程不安全

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

public static void main(String[] args) {

    Map<String, String> map = new HashMap<>();

    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、使用Collections.synchronizedMap(new HashMap<>());

2、使用 ConcurrentHashMap

Map<String, String> map = new ConcurrentHashMap<>();

第5章 值传递和引用传递

1 举例

/**
 * 值传递和引用传递
 */
class Person {
    private Integer id;
    private String personName;

    public Person(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

2 changeValue1的执行过程

八种基本数据类型,在栈里面分配内存,属于值传递

栈管运行,堆管存储

当们执行 changeValue1的时候,因为int是基本数据类型,所以传递的是int = 20这个值,相当于传递的是一个副本,main方法里面的age并没有改变,因此输出的结果 age还是20,属于值传递

image-20200314185317851

3 changeValue2的执行过程

因为Person是属于对象,传递的是内存地址,当执行changeValue2的时候,会改变内存中的Person的值,属于引用传递,两个指针都是指向同一个地址

image-20200314185528034

4 changeValue3的执行过程

String不属于基本数据类型,但是为什么执行完成后,还是abc呢?

这是因为String的特殊性,当我们执行String str = “abc”的时候,它会把 abc 放入常量池中

image-20200314190021466

当我们执行changeValue3的时候,会重新新建一个xxx,并没有销毁abc,然后指向xxx,然后最后我们输出的是main中的引用,还是指向的abc,因此最后输出结果还是abc

第6章 Lock

1 Java锁之公平锁和非公平锁

1.1 概念

公平锁

是指多个线程按照申请锁的顺序来获取锁,类似于排队买饭,先来后到,先来先服务,就是公平的,也就是队列

非公平锁

是指多个线程获取锁的顺序,并不是按照申请锁的顺序,有可能申请的线程比先申请的线程优先获取锁,在高并发环境下,有可能造成优先级翻转,或者饥饿的线程(也就是某个线程一直得不到锁)

1.2 如何创建

并发包中ReentrantLock的创建可以指定析构函数的boolean类型来得到公平锁或者非公平锁,默认是非公平锁

/**
* 创建一个可重入锁,true 表示公平锁,false 表示非公平锁。默认非公平锁
*/
Lock lock = new ReentrantLock(true);

1.3 两者区别

公平锁:就是很公平,在并发环境中,每个线程在获取锁时会先查看此锁维护的等待队列,如果为空,或者当前线程是等待队列中的第一个,就占用锁,否者就会加入到等待队列中,以后安装FIFO的规则从队列中取到自己

非公平锁: 非公平锁比较粗鲁,上来就直接尝试占有锁,如果尝试失败,就再采用类似公平锁那种方式。

1.4 题外话

Java ReenttrantLock通过构造函数指定该锁是否公平,默认是非公平锁,因为非公平锁的优点在于吞吐量比公平锁大,对于synchronized而言,也是一种非公平锁

2 可重入锁和递归锁ReentrantLock

2.1 概念

可重入锁就是递归锁

指的是同一线程外层函数获得锁之后,内层递归函数仍然能获取到该锁的代码,在同一线程在外层方法获取锁的时候,在进入内层方法会自动获取锁

也就是说:线程可以进入任何一个它已经拥有的锁所同步的代码块

ReentrantLock / Synchronized 就是一个典型的可重入锁

2.2 代码

可重入锁就是,在一个method1方法中加入一把锁,方法2也加锁了,那么他们拥有的是同一把锁

public synchronized void method1() {
	method2();
}

public synchronized void method2() {

}

也就是说我们只需要进入method1后,那么它也能直接进入method2方法,因为他们所拥有的锁,是同一把。

2.3 作用

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

2.4 可重入锁验证

证明Synchronized

/**
 * 可重入锁(也叫递归锁)
 * 指的是同一线程外层函数获得锁之后,内层递归函数仍然能获取到该锁的代码,在同一线程在外层方法获取锁的时候,在进入内层方法会自动获取锁
 *
 * 也就是说:`线程可以进入任何一个它已经拥有的锁所同步的代码块`
 */

/**
 * 资源类
 */
class Phone {

    /**
     * 发送短信
     * @throws Exception
     */
    public synchronized void sendSMS() throws Exception{
        System.out.println(Thread.currentThread().getName() + "\t invoked sendSMS()");

        // 在同步方法中,调用另外一个同步方法
        sendEmail();
    }

    /**
     * 发邮件
     * @throws Exception
     */
    public synchronized void sendEmail() throws Exception{
        System.out.println(Thread.currentThread().getId() + "\t invoked sendEmail()");
    }
}
public class ReenterLockDemo {


    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	 invoked sendSMS()
t1	 invoked sendEmail()
t2	 invoked sendSMS()
t2	 invoked sendEmail()

这就说明当 t1 线程进入sendSMS的时候,拥有了一把锁,同时t2线程无法进入,直到t1线程拿着锁,执行了sendEmail 方法后,才释放锁,这样t2才能够进入

t1	 invoked sendSMS()      t1线程在外层方法获取锁的时候
t1	 invoked sendEmail()    t1在进入内层方法会自动获取锁

t2	 invoked sendSMS()      t2线程在外层方法获取锁的时候
t2	 invoked sendEmail()    t2在进入内层方法会自动获取锁

证明ReentrantLock

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

/**
 * 资源类
 */
class Phone 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 ReenterLockDemo {


    public static void main(String[] args) {
        Phone phone = new Phone();

        /**
         * 因为Phone实现了Runnable接口
         */
        Thread t3 = new Thread(phone, "t3");
        Thread t4 = new Thread(phone, "t4");
        t3.start();
        t4.start();
    }
}

现在我们使用ReentrantLock进行验证,首先资源类实现了Runnable接口,重写Run方法,里面调用get方法,get方法在进入的时候,就加了锁

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

然后在方法里面,又调用另外一个加了锁的setLock方法

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

最后输出结果我们能发现,结果和加synchronized方法是一致的,都是在外层的方法获取锁之后,线程能够直接进入里层

t3	 get Lock
t3	 set Lock
t4	 get Lock
t4	 set Lock

当我们在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();
        lock.unlock();
    }
}

得到结果

t3	 get Lock
t3	 set Lock

也就是说程序直接卡死,线程不能出来,也就说明我们申请几把锁,最后需要解除几把锁

当我们只加一把锁,但是用两把锁来解锁的时候,又会出现什么情况呢?

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

这个时候,运行程序会直接报错

t3	 get Lock
t3	 set Lock
t4	 get Lock
t4	 set 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)

3 Java锁之自旋锁

自旋锁:spinlock,是指尝试获取锁的线程不会立即阻塞,而是采用循环的方式去尝试获取锁,这样的好处是减少线程上下文切换的消耗,缺点是循环会消耗CPU

原来提到的比较并交换,底层使用的就是自旋,自旋就是多次尝试,多次访问,不会阻塞的状态就是自旋。

image-20200315154143781

3.1 优缺点

优点:循环比较获取直到成功为止,没有类似于wait的阻塞

缺点:当不断自旋的线程越来越多的时候,会因为执行while循环不断的消耗CPU资源

3.2 手写自旋锁

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


/**
 * 手写一个自旋锁
 *
 * 循环比较获取直到成功为止,没有类似于wait的阻塞
 *
 * 通过CAS操作完成自旋锁,A线程先进来调用myLock方法自己持有锁5秒,B随后进来发现当前有线程持有锁,不是null,所以只能通过自旋等待,直到A释放锁后B随后抢到
 */
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
.....一秒后.....
t2 come in
.....五秒后.....
t1 invoked myUnlock()
t2 invoked myUnlock()

首先输出的是 t1 come in

然后1秒后,t2线程启动,发现锁被t1占有,所有不断的执行 compareAndSet方法,来进行比较,直到t1释放锁后,也就是5秒后,t2成功获取到锁,然后释放

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

4.1 概念

独占锁:指该锁一次只能被一个线程所持有。对ReentrantLock和Synchronized而言都是独占锁

共享锁:指该锁可以被多个线程锁持有

对ReentrantReadWriteLock其读锁是共享,其写锁是独占

写的时候只能一个人写,但是读的时候,可以多个人同时读

4.2 为什么会有写锁和读锁

原来我们使用ReentrantLock创建锁的时候,是独占锁,也就是说一次只能一个线程访问,但是有一个读写分离场景,读的时候想同时进行,因此原来独占锁的并发性就没这么好了,因为读锁并不会造成数据不一致的问题,因此可以多个人共享读

多个线程 同时读一个资源类没有任何问题,所以为了满足并发量,读取共享资源应该可以同时进行,但是如果一个线程想去写共享资源,就不应该再有其它线程可以对该资源进行读或写

读-读:能共存

读-写:不能共存

写-写:不能共存

4.3 代码实现

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

/**
 * 读写锁
 * 多个线程 同时读一个资源类没有任何问题,所以为了满足并发量,读取共享资源应该可以同时进行
 * 但是,如果一个线程想去写共享资源,就不应该再有其它线程可以对该资源进行读或写
 *
 */

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();
        }
    }
}

我们分别创建5个线程写入缓存

// 线程操作资源类,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个线程读取缓存,

// 线程操作资源类, 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

我们可以看到,在写入的时候,写操作都没其它线程打断了,这就造成了,还没写完,其它线程又开始写,这样就造成数据不一致

4.4 解决方法

上面的代码是没有加锁的,这样就会造成线程在进行写入操作的时候,被其它线程频繁打断,从而不具备原子性,这个时候,我们就需要用到读写锁来解决了

/**
* 创建一个读写锁
* 它是一个读写融为一体的锁,在使用的时候,需要转换
*/
private ReentrantReadWriteLock rwLock = new ReentrantReadWriteLock();

当我们在进行写操作的时候,就需要转换成写锁

// 创建一个写锁
rwLock.writeLock().lock();

// 写锁 释放
rwLock.writeLock().unlock();

当们在进行读操作的时候,在转换成读锁

// 创建一个读锁
rwLock.readLock().lock();

// 读锁 释放
rwLock.readLock().unlock();

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

完整代码:

/**
 * 读写锁
 * 多个线程 同时读一个资源类没有任何问题,所以为了满足并发量,读取共享资源应该可以同时进行
 * 但是,如果一个线程想去写共享资源,就不应该再有其它线程可以对该资源进行读或写
 *
 */

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

从运行结果我们可以看出,写入操作是一个一个线程进行执行的,并且中间不会被打断,而读操作的时候,是同时5个线程进入,然后并发读取操作

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

5.1 前言

首先我们要分析下这道题,这简单的一个问题,其实里面还是包含了很多信息的,要想回答好这个问题,面试者至少要知道一下概念:

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

5.2 标准解答

为了进一步提升计算机各方面能力,在硬件层面做了很多优化,如处理器优化和指令重排等,但是这些技术的引入就会导致有序性问题。

先解释什么是有序性问题,也知道是什么原因导致的有序性问题

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

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

但是,虽然很多硬件都会为了优化做一些重排,但是在Java中,不管怎么排序,都不能影响单线程程序的执行结果。这就是as-if-serial语义,所有硬件优化的前提都是必须遵守as-if-serial语义。

as-if-serial语义把单线程程序保护了起来,遵守as-if-serial语义的编译器,runtime 和处理器共同为编写单线程程序的程序员创建了一个幻觉:单线程程序是按程序的顺序来执行的。as-if-serial语义使单线程程序员无需担心重排序会 干扰他们,也无需担心内存可见性问题。

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

再说下synchronized,他是Java提供的锁,可以通过他对Java中的对象加锁,并且他是一种排他的、可重入的锁。

所以,当某个线程执行到一段被synchronized修饰的代码之前,会先进行加锁,执行完之后再进行解锁。在加锁之后,解锁之前,其他线程是无法再次获得锁的,只有这条加锁线程可以重复获得该锁。

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

synchronized通过排他锁的方式就保证了同一时间内,被synchronized修饰的代码是单线程执行的。所以呢,这就满足了as-if-serial语义的一个关键前提,那就是单线程,因为有as-if-serial语义保证,单线程的有序性就天然存在了。

第7章 计时器和信号量

1 CountDownLatch

1.1 概念

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

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

1.2 场景

现在有这样一个场景,假设一个自习室里有7个人,其中有一个是班长,班长的主要职责就是在其它6个同学走了后,关灯,锁教室门,然后走人,因此班长是需要最后一个走的,那么有什么方法能够控制班长这个线程是最后一个执行,而其它线程是随机执行的

1.3 解决方案

这个时候就用到了CountDownLatch,计数器了。我们一共创建6个线程,然后计数器的值也设置成6

// 计数器
CountDownLatch countDownLatch = new CountDownLatch(6);

然后每次学生线程执行完,就让计数器的值减1

for (int i = 0; i <= 6; i++) {
    new Thread(() -> {
        System.out.println(Thread.currentThread().getName() + "\t 上完自习,离开教室");
        countDownLatch.countDown();
    }, String.valueOf(i)).start();
}

最后我们需要通过CountDownLatch的await方法来控制班长主线程的执行,这里 countDownLatch.await()可以想成是一道墙,只有当计数器的值为0的时候,墙才会消失,主线程才能继续往下执行

countDownLatch.await();

System.out.println(Thread.currentThread().getName() + "\t 班长最后关门");

不加CountDownLatch的执行结果,我们发现main线程提前已经执行完成了

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

引入CountDownLatch后的执行结果,我们能够控制住main方法的执行,这样能够保证前提任务的执行

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

1.4 完整代码

package com.moxi.interview.study.thread;

import java.util.concurrent.CountDownLatch;

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

        // 计数器
        CountDownLatch countDownLatch = new CountDownLatch(6);

        for (int i = 0; i <= 6; i++) {
            new Thread(() -> {
                System.out.println(Thread.currentThread().getName() + "\t 上完自习,离开教室");
                countDownLatch.countDown();
            }, String.valueOf(i)).start();
        }

        countDownLatch.await();

        System.out.println(Thread.currentThread().getName() + "\t 班长最后关门");
    }
}

2 CyclicBarrier

2.1 概念

和CountDownLatch相反,需要集齐七颗龙珠,召唤神龙。也就是做加法,开始是0,加到某个值的时候就执行

CyclicBarrier的字面意思就是可循环(cyclic)使用的屏障(Barrier)。它要求做的事情是,让一组线程到达一个屏障(也可以叫同步点)时被阻塞,直到最后一个线程到达屏障时,屏障才会开门,所有被屏障拦截的线程才会继续干活,线程进入屏障通过CyclicBarrier的await方法

2.2 案例

集齐7个龙珠,召唤神龙的Demo,我们需要首先创建CyclicBarrier

/**
* 定义一个循环屏障,参数1:需要累加的值,参数2 需要执行的方法
*/
CyclicBarrier cyclicBarrier = new CyclicBarrier(7, () -> {
	System.out.println("召唤神龙");
});

然后同时编写七个线程,进行龙珠收集,但一个线程收集到了的时候,我们需要让他执行await方法,等待到7个线程全部执行完毕后,我们就执行原来定义好的方法

for (int i = 0; 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();
}

完整代码

/**
 * CyclicBarrier循环屏障
 *
 */
public class CyclicBarrierDemo {


    public static void main(String[] args) {
        /**
         * 定义一个循环屏障,参数1:需要累加的值,参数2 需要执行的方法
         */
        CyclicBarrier cyclicBarrier = new CyclicBarrier(7, () -> {
            System.out.println("召唤神龙");
        });

        for (int i = 0; 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();
        }
    }
}

3 Semaphore:信号量

3.1 概念

信号量主要用于两个目的

  • 一个是用于共享资源的互斥使用
  • 另一个用于并发线程数的控制

3.2 代码

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

那么我们首先需要定义信号量为3,也就是3个停车位

/**
* 初始化一个信号量为3,默认是false 非公平锁, 模拟3个停车位
*/
Semaphore semaphore = new Semaphore(3, false);

然后我们模拟6辆车同时并发抢占停车位,但第一个车辆抢占到停车位后,信号量需要减1

// 代表一辆车,已经占用了该车位
semaphore.acquire(); // 抢占

同时车辆假设需要等待3秒后,释放信号量

// 每个车停3秒
try {
	TimeUnit.SECONDS.sleep(3);
} catch (InterruptedException e) {
	e.printStackTrace();
}

最后车辆离开,释放信号量

// 释放停车位
semaphore.release();

3.3 完整代码

/**
 * 信号量Demo
 * @author: 陌溪
 * @create: 2020-03-16-15:01
 */
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();
        }
    }
}

运行结果

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

看运行结果能够发现,0 2 1 车辆首先抢占到了停车位,然后等待3秒后,离开,然后后面 3 4 5 又抢到了车位

第8章阻塞队列

1 概念

1.1 队列

队列就可以想成是一个数组,从一头进入,一头出去,排队买饭

1.2 阻塞队列

BlockingQueue 阻塞队列,排队拥堵,首先它是一个队列,而一个阻塞队列在数据结构中所起的作用大致如下图所示:

image-20200316152120272

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

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

    • 当蛋糕店的柜子空的时候,无法从柜子里面获取蛋糕
  • 当阻塞队列是满时,从队列中添加元素的操作将会被阻塞

    • 当蛋糕店的柜子满的时候,无法继续向柜子里面添加蛋糕了

也就是说 试图从空的阻塞队列中获取元素的线程将会被阻塞,直到其它线程往空的队列插入新的元素

同理,试图往已经满的阻塞队列中添加新元素的线程,直到其它线程往满的队列中移除一个或多个元素,或者完全清空队列后,使队列重新变得空闲起来,并后续新增

2 为什么要用?

去海底捞吃饭,大厅满了,需要进候厅等待,但是这些等待的客户能够对商家带来利润,因此我们非常欢迎他们阻塞

在多线程领域:所谓的阻塞,在某些清空下会挂起线程(即阻塞),一旦条件满足,被挂起的线程又会自动唤醒

为什么需要BlockingQueue

好处是我们不需要关心什么时候需要阻塞线程,什么时候需要唤醒线程,因为这一切BlockingQueue都帮你一手包办了

在concurrent包发布以前,在多线程环境下,我们每个程序员都必须自己取控制这些细节,尤其还要兼顾效率和线程安全,而这会给我们的程序带来不小的复杂度。

3 架构

// 你用过List集合类

// ArrayList集合类熟悉么?

// 还用过 CopyOnWriteList  和 BlockingQueue

BlockingQueue阻塞队列是属于一个接口,底下有七个实现类

  • ArrayBlockQueue:由数组结构组成的有界阻塞队列
  • LinkedBlockingQueue:由链表结构组成的有界(但是默认大小 Integer.MAX_VALUE)的阻塞队列
    • 有界,但是界限非常大,相当于无界,可以当成无界
  • PriorityBlockQueue:支持优先级排序的无界阻塞队列
  • DelayQueue:使用优先级队列实现的延迟无界阻塞队列
  • SynchronousQueue:不存储元素的阻塞队列,也即单个元素的队列
    • 生产一个,消费一个,不存储元素,不消费不生产
  • LinkedTransferQueue:由链表结构组成的无界阻塞队列
  • LinkedBlockingDeque:由链表结构组成的双向阻塞队列

这里需要掌握的是:ArrayBlockQueue、LinkedBlockingQueue、SynchronousQueue

4 BlockingQueue核心方法

image-20200316154442756

抛出异常 当阻塞队列满时:在往队列中add插入元素会抛出 IIIegalStateException:Queue full 当阻塞队列空时:再往队列中remove移除元素,会抛出NoSuchException
特殊性 插入方法,成功true,失败false 移除方法:成功返回出队列元素,队列没有就返回空
一直阻塞 当阻塞队列满时,生产者继续往队列里put元素,队列会一直阻塞生产线程直到put数据or响应中断退出, 当阻塞队列空时,消费者线程试图从队列里take元素,队列会一直阻塞消费者线程直到队列可用。
超时退出 当阻塞队列满时,队里会阻塞生产者线程一定时间,超过限时后生产者线程会退出

4.1 抛出异常组

但执行add方法,向已经满的ArrayBlockingQueue中添加元素时候,会抛出异常

// 阻塞队列,需要填入默认值
BlockingQueue<String> blockingQueue = new ArrayBlockingQueue<>(3);

System.out.println(blockingQueue.add("a"));
System.out.println(blockingQueue.add("b"));
System.out.println(blockingQueue.add("c"));

System.out.println(blockingQueue.add("XXX"));

运行后:

true
true
true
Exception in thread "main" java.lang.IllegalStateException: Queue full
	at java.util.AbstractQueue.add(AbstractQueue.java:98)
	at java.util.concurrent.ArrayBlockingQueue.add(ArrayBlockingQueue.java:312)
	at com.moxi.interview.study.queue.BlockingQueueDemo.main(BlockingQueueDemo.java:25)

同时如果我们多取出元素的时候,也会抛出异常,我们假设只存储了3个值,但是取的时候,取了四次

// 阻塞队列,需要填入默认值
BlockingQueue<String> blockingQueue = new ArrayBlockingQueue<>(3);
System.out.println(blockingQueue.add("a"));
System.out.println(blockingQueue.add("b"));
System.out.println(blockingQueue.add("c"));

System.out.println(blockingQueue.remove());
System.out.println(blockingQueue.remove());
System.out.println(blockingQueue.remove());
System.out.println(blockingQueue.remove());

那么出现异常

true
true
true
a
b
c
Exception in thread "main" java.util.NoSuchElementException
	at java.util.AbstractQueue.remove(AbstractQueue.java:117)
	at com.moxi.interview.study.queue.BlockingQueueDemo.main(BlockingQueueDemo.java:30)

4.2 布尔类型组

我们使用 offer的方法,添加元素时候,如果阻塞队列满了后,会返回false,否者返回true

同时在取的时候,如果队列已空,那么会返回null

BlockingQueue 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

4.3 阻塞队列组

我们使用 put的方法,添加元素时候,如果阻塞队列满了后,添加消息的线程,会一直阻塞,直到队列元素减少,会被清空,才会唤醒

一般在消息中间件,比如RabbitMQ中会使用到,因为需要保证消息百分百不丢失,因此只有让它阻塞

BlockingQueue<String> blockingQueue = new ArrayBlockingQueue<>(3);
blockingQueue.put("a");
blockingQueue.put("b");
blockingQueue.put("c");
System.out.println("================");

blockingQueue.take();
blockingQueue.take();
blockingQueue.take();
blockingQueue.take();

同时使用take取消息的时候,如果内容不存在的时候,也会被阻塞

4.4 不见不散组

offer( ) , poll 加时间

使用offer插入的时候,需要指定时间,如果2秒还没有插入,那么就放弃插入

BlockingQueue<String> blockingQueue = new ArrayBlockingQueue<>(3);
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(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));

如果2秒内取不出来,那么就返回null

5 SynchronousQueue

SynchronousQueue没有容量,与其他BlockingQueue不同,SynchronousQueue是一个不存储的BlockingQueue,每一个put操作必须等待一个take操作,否者不能继续添加元素

下面我们测试SynchronousQueue添加元素的过程

首先我们创建了两个线程,一个线程用于生产,一个线程用于消费

生产的线程分别put了 A、B、C这三个字段

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();

消费线程使用take,消费阻塞队列中的内容,并且每次消费前,都等待5秒

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();

最后结果输出为:

t1	 put A 
t2	 take A 

5秒后...

t1	 put B 
t2	 take B 

5秒后...

t1	 put C 
t2	 take C

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

6 阻塞队列的用处

生产者消费者模式

一个初始值为0的变量,两个线程对其交替操作,一个加1,一个减1,来5轮

关于多线程的操作,我们需要记住下面几句

  • 线程 操作 资源类
  • 判断 干活 通知
  • 防止虚假唤醒机制

我们下面实现一个简单的生产者消费者模式,首先有资源类ShareData

/**
 * 资源类
 */
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();
        }
    }

}

里面有一个number变量,同时提供了increment 和 decrement的方法,分别让number 加1和减1

但是我们在进行判断的时候,为了防止出现虚假唤醒机制,不能使用if来进行判断,而应该使用while

// 判断
while(number != 0) {
    // 等待不能生产
    condition.await();
}

不能使用 if判断

// 判断
if(number != 0) {
    // 等待不能生产
    condition.await();
}

完整代码

/**
 * 生产者消费者 传统版
 * 题目:一个初始值为0的变量,两个线程对其交替操作,一个加1,一个减1,来5轮
 */
/**
 * 线程 操作 资源类
 * 判断 干活 通知
 * 防止虚假唤醒机制
 */

/**
 * 资源类
 */
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 ProdConsumerTraditionDemo {

    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
t1	 1
t2	 0
t1	 1
t2	 0
t1	 1
t2	 0
t1	 1
t2	 0

7 生成者和消费者3.0

在concurrent包发布以前,在多线程环境下,我们每个程序员都必须自己去控制这些细节,尤其还要兼顾效率和线程安全,则这会给我们的程序带来不小的时间复杂度

现在我们使用新版的阻塞队列版生产者和消费者,使用:volatile、CAS、atomicInteger、BlockQueue、线程交互、原子引用

/**
 * 生产者消费者  阻塞队列版
 * 使用:volatile、CAS、atomicInteger、BlockQueue、线程交互、原子引用
 */

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());
    }

    /**
     * 生产
     * @throws Exception
     */
    public void myProd() 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,生产介绍");
    }

    /**
     * 消费
     * @throws Exception
     */
    public void myConsumer() throws Exception{
        String retValue;
        // 多线程环境的判断,一定要使用while进行,防止出现虚假唤醒
        // 当FLAG为true的时候,开始生产
        while(FLAG) {
            // 2秒存入1个data
            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 ProdConsumerBlockingQueueDemo {

    public static void main(String[] args) {
        // 传入具体的实现类, ArrayBlockingQueue
        MyResource myResource = new MyResource(new ArrayBlockingQueue<String>(10));

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


        new Thread(() -> {
            System.out.println(Thread.currentThread().getName() + "\t 消费线程启动");

            try {
                myResource.myConsumer();
            } catch (Exception e) {
                e.printStackTrace();
            }
        }, "consumer").start();

        // 5秒后,停止生产和消费
        try {
            TimeUnit.SECONDS.sleep(5);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        System.out.println("");
        System.out.println("");
        System.out.println("5秒中后,生产和消费线程停止,线程结束");
        myResource.stop();
    }
}

最后运行结果

java.util.concurrent.ArrayBlockingQueue
prod	 生产线程启动


consumer	 消费线程启动
prod	 插入队列:1成功
consumer	 消费队列:1成功
prod	 插入队列:2成功
consumer	 消费队列:2成功
prod	 插入队列:3成功
consumer	 消费队列:3成功
prod	 插入队列:4成功
consumer	 消费队列:4成功
prod	 插入队列:5成功
consumer	 消费队列:5成功


5秒中后,生产和消费线程停止,线程结束
prod	 停止生产,表示FLAG=false,生产介绍

第9章 Synchronized和Lock的区别

1 前言

早期的时候我们对线程的主要操作为:

  • synchronized wait notify

然后后面出现了替代方案

  • lock await signal

image-20200317101210376

2 问题

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

  • 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那样,要么随机,要么全部唤醒

3 举例

针对刚刚提到的区别的第5条,我们有下面这样的一个场景

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

我们会发现,这样的场景在使用synchronized来完成的话,会非常的困难,但是使用lock就非常方便了

也就是我们需要实现一个链式唤醒的操作

image-20200317105121435

当A线程执行完后,B线程才能执行,然后B线程执行完成后,C线程才执行

首先我们需要创建一个重入锁

// 创建一个重入锁
private Lock lock = new ReentrantLock();

然后定义三个条件,也可以称为锁的钥匙,通过它就可以获取到锁,进入到方法里面

// 这三个相当于备用钥匙
private Condition condition1 = lock.newCondition();
private Condition condition2 = lock.newCondition();
private Condition condition3 = lock.newCondition();

然后开始记住锁的三部曲: 判断 干活 唤醒

这里的判断,为了避免虚假唤醒,一定要采用 while

干活就是把需要的内容,打印出来

唤醒的话,就是修改资源类的值,然后精准唤醒线程进行干活:这里A 唤醒B, B唤醒C,C又唤醒A

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();
    }

/**
 * Synchronized 和 Lock的区别
 */

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) {
                // 不等于2,需要等待
                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) {
                // 不等于3,需要等待
                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 SyncAndReentrantLockDemo {

    public static void main(String[] args) {

        ShareResource shareResource = new ShareResource();

        new Thread(() -> {
            for (int i = 0; i < 10; i++) {
                    shareResource.print5();
            }
        }, "A").start();

        new Thread(() -> {
            for (int i = 0; i < 10; i++) {
                shareResource.print10();
            }
        }, "B").start();

        new Thread(() -> {
            for (int i = 0; i < 10; i++) {
                shareResource.print15();
            }
        }, "C").start();
    }
}

第10章线程池

1 前言

获取多线程的方法,我们都知道有三种,还有一种是实现Callable接口

  • 实现Runnable接口
  • 实现Callable接口
  • 实例化Thread类
  • 使用线程池获取

2 Callable接口

Callable接口,是一种让线程执行完成后,能够返回结果的

在说到Callable接口的时候,我们不得不提到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线程, 将MyThread2实现Callable接口的类包装起来

这里需要用到的是FutureTask类,他实现了Runnable接口,并且还需要传递一个实现Callable接口的类作为构造函数

// 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线程的计算结果,如果没有计算完成就要去强求,会导致阻塞,直到计算完成

image-20200317152541284

也就是说 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();

3 ThreadPoolExecutor

3.1 为什么用线程池

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

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

线程池中的任务是放入到阻塞队列中的

3.2 线程池的好处

多核处理的好处是:省略的上下文的切换开销

原来我们实例化对象的时候,是使用 new关键字进行创建,到了Spring后,我们学了IOC依赖注入,发现Spring帮我们将对象已经加载到了Spring容器中,只需要通过@Autowrite注解,就能够自动注入,从而使用

因此使用多线程有下列的好处

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

3.3 架构说明

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

image-20200317175202647

image-20200317175241007

3.4 创建线程池

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

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

// 一池5个处理线程(用池化技术,一定要记得关闭)
ExecutorService threadPool = Executors.newFixedThreadPool(5);

// 创建一个只有一个线程的线程池
ExecutorService threadPool = Executors.newSingleThreadExecutor();

// 创建一个拥有N个线程的线程池,根据调度创建合适的线程
ExecutorService threadPool = Executors.newCacheThreadPool();

然后我们执行下面的的应用场景

模拟10个用户来办理业务,每个用户就是一个来自外部请求线程

我们需要使用 threadPool.execute执行业务,execute需要传入一个实现了Runnable接口的线程

threadPool.execute(() -> {
	System.out.println(Thread.currentThread().getName() + "\t 给用户办理业务");
});

然后我们使用完毕后关闭线程池

threadPool.shutdown();

完整代码为:

/**
 * 第四种获取 / 使用 Java多线程的方式,通过线程池
 */
public class MyThreadPoolDemo {
    public static void main(String[] args) {

        // Array  Arrays(辅助工具类)
        // Collection Collections(辅助工具类)
        // Executor Executors(辅助工具类)


        // 一池5个处理线程(用池化技术,一定要记得关闭)
        ExecutorService threadPool = Executors.newFixedThreadPool(5);

        // 模拟10个用户来办理业务,每个用户就是一个来自外部请求线程
        try {

            // 循环十次,模拟业务办理,让5个线程处理这10个请求
            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-5	 给用户:4 办理业务
pool-1-thread-1	 给用户:5 办理业务
pool-1-thread-4	 给用户:3 办理业务
pool-1-thread-2	 给用户:1 办理业务
pool-1-thread-3	 给用户:2 办理业务
pool-1-thread-2	 给用户:9 办理业务
pool-1-thread-4	 给用户:8 办理业务
pool-1-thread-1	 给用户:7 办理业务
pool-1-thread-5	 给用户:6 办理业务

我们能够看到,一共有5个线程,在给10个用户办理业务

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

Executors.newScheduledThreadPool(int corePoolSize):

线程池支持定时以及周期性执行任务,创建一个corePoolSize为传入参数,最大线程数为整形的最大数的线程池

底层使用 ScheduledThreadPoolExecutor 来实现 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 RejectedExecutionException {@inheritDoc}
     * @throws NullPointerException       {@inheritDoc}
     * @throws IllegalArgumentException   {@inheritDoc}
     * command:执行的任务 Callable或Runnable接口实现类
	 * initialDelay 第一次执行任务延迟时间
	 * period 连续执行任务之间的周期,从上一个任务开始执行时计算延迟多少开始执行下一个任务,但是还会等上一个任务结束之后。
	 * unit:延迟时间单位
     */
public ScheduledFuture<?> scheduleAtFixedRate(Runnable command, long initialDelay, long period, TimeUnit unit)
/**
     * @throws RejectedExecutionException {@inheritDoc}
     * @throws NullPointerException       {@inheritDoc}
     * @throws IllegalArgumentException   {@inheritDoc}
     * command:执行的任务 Callable或Runnable接口实现类
	 * initialDelay 第一次执行任务延迟时间
	 * delay:连续执行任务之间的周期,从上一个任务全部执行完成时计算延迟多少开始执行下一个任务
	 * unit:延迟时间单位
     */
public ScheduledFuture<?> scheduleWithFixedDelay(Runnable command, long initialDelay, long delay, TimeUnit unit)

4 底层实现

我们通过查看源码,点击了Executors.newSingleThreadExecutor 和 Executors.newFixedThreadPool能够发现底层都是使用了ThreadPoolExecutor

image-20200317182004293

我们可以看到线程池的内部,还使用到了LinkedBlockingQueue 链表阻塞队列

同时在查看Executors.newCacheThreadPool 看到底层用的是 SynchronousBlockingQueue阻塞队列

最后查看一下,完整的三个创建线程的方法

image-20200317183202992

5 线程池的重要参数

image-20200317183600957

线程池在创建的时候,一共有7大参数

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

当营业窗口和阻塞队列中都满了时候,就需要设置拒绝策略

image-20200317201150197

6 拒绝策略

以下所有拒绝策略都实现了RejectedExecutionHandler接口

  • AbortPolicy:默认,直接抛出RejectedExcutionException异常,阻止系统正常运行
  • DiscardPolicy:直接丢弃任务,不予任何处理也不抛出异常,如果运行任务丢失,这是一种好方案
  • CallerRunsPolicy:该策略既不会抛弃任务,也不会抛出异常,而是将某些任务回退到调用者
  • DiscardOldestPolicy:抛弃队列中等待最久的任务,然后把当前任务加入队列中尝试再次提交当前任务

7 线程池底层工作原理

线程池运行架构图

image-20200318154414717

文字说明

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

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

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

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

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

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

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

8 为什么不用默认创建的线程池?

线程池创建的方法有:固定数的,单一的,可变的,那么在实际开发中,应该使用哪个?

我们一个都不用,在生产环境中是使用自己自定义的

为什么不用 Executors 中JDK提供的?

根据阿里巴巴手册:并发控制这章

  • 线程资源必须通过线程池提供,不允许在应用中自行显式创建线程
    • 使用线程池的好处是减少在创建和销毁线程上所消耗的时间以及系统资源的开销,解决资源不足的问题,如果不使用线程池,有可能造成系统创建大量同类线程而导致消耗完内存或者“过度切换”的问题
  • 线程池不允许使用Executors去创建,而是通过 ThreadPoolExecutor 的方式,这样的处理方式让写的同学更加明确线程池的运行规则,规避资源耗尽的风险
    • Executors返回的线程池对象弊端如下:
      • FixedThreadPool和SingleThreadPool:
        • 运行的请求队列长度为:Integer.MAX_VALUE,可能会堆积大量的请求,从而导致OOM
      • CacheThreadPool和ScheduledThreadPool
        • 运行的请求队列长度为:Integer.MAX_VALUE,线程数上限太大导致oom

9 手写线程池

9.1 采用默认拒绝策略

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

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

// 手写线程池
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());

然后使用for循环,模拟10个用户来进行请求

// 模拟10个用户来办理业务,每个用户就是一个来自外部请求线程
try {

    // 循环十次,模拟业务办理,让5个线程处理这10个请求
    for (int i = 0; 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	 给用户:0 办理业务
pool-1-thread-4	 给用户:6 办理业务
pool-1-thread-3	 给用户:5 办理业务
pool-1-thread-2	 给用户:1 办理业务
pool-1-thread-2	 给用户:4 办理业务
pool-1-thread-5	 给用户:7 办理业务
pool-1-thread-4	 给用户:2 办理业务
pool-1-thread-3	 给用户:3 办理业务
java.util.concurrent.RejectedExecutionException: Task com.moxi.interview.study.thread.MyThreadPoolDemo$$Lambda$1/1747585824@4dd8dc3 rejected from java.util.concurrent.ThreadPoolExecutor@6d03e736[Running, pool size = 5, active threads = 3, queued tasks = 0, completed tasks = 5]
	at java.util.concurrent.ThreadPoolExecutor$AbortPolicy.rejectedExecution(ThreadPoolExecutor.java:2047)
	at java.util.concurrent.ThreadPoolExecutor.reject(ThreadPoolExecutor.java:823)
	at java.util.concurrent.ThreadPoolExecutor.execute(ThreadPoolExecutor.java:1369)
	at com.moxi.interview.study.thread.MyThreadPoolDemo.main(MyThreadPoolDemo.java:34)

这是因为触发了拒绝策略,而我们设置的拒绝策略是默认的AbortPolicy,也就是抛异常的

触发条件是,请求的线程大于 阻塞队列大小 + 最大线程数 = 8 的时候,也就是说第9个线程来获取线程池中的线程时,就会抛出异常从而报错退出。

9.2 采用CallerRunsPolicy拒绝策略

当我们更好其它的拒绝策略时,采用CallerRunsPolicy拒绝策略,也称为回退策略,就是把任务丢回原来的请求开启线程着,我们看运行结果

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

我们发现,输出的结果里面出现了main线程,因为线程池出发了拒绝策略,把任务回退到main线程,然后main线程对任务进行处理

9.3 采用 DiscardPolicy 拒绝策略

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

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

9.4 采用DiscardOldestPolicy拒绝策略

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

这个策略和刚刚差不多,会把最久的队列中的任务替换掉

10 线程池的合理参数

生产环境中如何配置 corePoolSize 和 maximumPoolSize

这个是根据具体业务来配置的,分为CPU密集型和IO密集型

  • CPU密集型

CPU密集的意思是该任务需要大量的运算,而没有阻塞,CPU一直全速运行

CPU密集任务只有在真正的多核CPU上才可能得到加速(通过多线程)

而在单核CPU上,无论你开几个模拟的多线程该任务都不可能得到加速,因为CPU总的运算能力就那些

CPU密集型任务配置尽可能少的线程数量:

一般公式:CPU核数 + 1个线程数

  • IO密集型

由于IO密集型任务线程并不是一直在执行任务,则可能多的线程,如 CPU核数 * 2

IO密集型,即该任务需要大量的IO操作,即大量的阻塞

在单线程上运行IO密集型的任务会导致浪费大量的CPU运算能力花费在等待上

所以IO密集型任务中使用多线程可以大大的加速程序的运行,即使在单核CPU上,这种加速主要就是利用了被浪费掉的阻塞时间。

IO密集时,大部分线程都被阻塞,故需要多配置线程数:

参考公式:CPU核数 / (1 - 阻塞系数) 阻塞系数在0.8 ~ 0.9左右

例如:8核CPU:8/ (1 - 0.9) = 80个线程数

第11章 死锁编码及定位分析

1 概念

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

image-20200318175441578

2 产生死锁的原因

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

3 死锁产生的四个必要条件

  • 互斥
    • 解决方法:把互斥的共享资源封装成可同时访问
  • 占有且等待
    • 解决方法:进程请求资源时,要求它不占有任何其它资源,也就是它必须一次性申请到所有的资源,这种方式会导致资源效率低。
  • 非抢占式
    • 解决方法:如果进程不能立即分配资源,要求它不占有任何其他资源,也就是只能够同时获得所有需要资源时,才执行分配操作
  • 循环等待
    • 解决方法:对资源进行排序,要求进程按顺序请求资源。

4 死锁代码

我们创建了一个资源类,然后让两个线程分别持有自己的锁,同时在尝试获取别人的,就会出现死锁现象

/**
 * 死锁小Demo
 * 死锁是指两个或多个以上的进程在执行过程中,
 * 因争夺资源而造成一种互相等待的现象,
 * 若无外力干涉那他们都将无法推进下去
 */

import java.util.concurrent.TimeUnit;

/**
 * 资源类
 */
class HoldLockThread implements Runnable{

    private String lockA;
    private String lockB;

    // 持有自己的锁,还想得到别人的锁

    public HoldLockThread(String lockA, String lockB) {
        this.lockA = lockA;
        this.lockB = lockB;
    }


    @Override
    public void run() {
        synchronized (lockA) {
            System.out.println(Thread.currentThread().getName() + "\t 自己持有" + lockA + "\t 尝试获取:" + lockB);

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

            synchronized (lockB) {
                System.out.println(Thread.currentThread().getName() + "\t 自己持有" + lockB + "\t 尝试获取:" + lockA);
            }
        }
    }
}
public class DeadLockDemo {
    public static void main(String[] args) {
        String lockA = "lockA";
        String lockB = "lockB";

        new Thread(new HoldLockThread(lockA, lockB), "t1").start();

        new Thread(new HoldLockThread(lockB, lockA), "t2").start();
    }
}

运行结果,main线程无法结束

t1	 自己持有lockA	 尝试获取:lockB
t2	 自己持有lockB	 尝试获取:lockA

5 如何排查死锁

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

jps -l

我们能看到DeadLockDemo这个类,一直在运行

image-20200318181504703

在使用jstack查看堆栈信息

jstack  7560   # 后面参数是 jps输出的该类的pid

得到的结果

Found one Java-level deadlock:
=============================
"t2":
  waiting to lock monitor 0x000000001cfc0de8 (object 0x000000076b696e80, a java.lang.String),
  which is held by "t1"
"t1":
  waiting to lock monitor 0x000000001cfc3728 (object 0x000000076b696eb8, a java.lang.String),
  which is held by "t2"

Java stack information for the threads listed above:
===================================================
"t2":
        at com.moxi.interview.study.Lock.HoldLockThread.run(DeadLockDemo.java:42)
        - waiting to lock <0x000000076b696e80> (a java.lang.String)
        - locked <0x000000076b696eb8> (a java.lang.String)
        at java.lang.Thread.run(Thread.java:745)
"t1":
        at com.moxi.interview.study.Lock.HoldLockThread.run(DeadLockDemo.java:42)
        - waiting to lock <0x000000076b696eb8> (a java.lang.String)
        - locked <0x000000076b696e80> (a java.lang.String)
        at java.lang.Thread.run(Thread.java:745)

Found 1 deadlock.

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

第二部分 JVM

第 12 章 JVM

1 JVM体系结构

1.1 概览

image-20200318182540332

java gc 主要回收的是 方法区 和 堆中的内容

image-20200318184401133

1.2 类加载器

  • 类加载器是什么
  • 双亲委派机制
  • Java类加载的沙箱安全机制

1.3 常见的垃圾回收算法

  • 引用计数

image-20200318184508982

在双端循环,互相引用的时候,容易报错,目前很少使用这种方式了

  • 复制

复制算法在年轻代的时候,进行使用,复制时候有交换

image-20200318184759295

image-20200318184820787

优点:没有产生内存碎片

  • 标记清除

先标记,后清除,缺点是会产生内存碎片,用于老年代多一些

image-20200318184944878

  • 标记整理

标记清除整理

image-20200318185100936

但是需要付出代价,因为移动对象需要成本

2 JVM 面试题

2.1 是么是 GCRoots 能做什么

1 什么是垃圾

简单来说就是内存中已经不再被使用的空间就是垃圾

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

2.1 引用计数法

Java中,引用和对象是有关联的。如果要操作对象则必须用引用进行。

因此,很显然一个简单的办法就是通过引用计数来判断一个对象是否可以回收。简单说,给对象中添加一个引用计数器

每当有一个地方引用它,计数器值加1

每当有一个引用失效,计数器值减1

任何时刻计数器值为零的对象就是不可能再被使用的,那么这个对象就是可回收对象。

那么为什么主流的Java虚拟机里面都没有选用这个方法呢?其中最主要的原因是它很难解决对象之间相互循环引用的问题。

该算法存在但目前无人用了,解决不了循环引用的问题,了解即可。

image-20200318213301603

2.2 枚举根节点做可达性分析

根搜索路径算法

为了解决引用计数法的循环引用个问题,Java使用了可达性分析的方法:

image-20200319113611244

所谓 GC Roots 或者说 Tracing Roots的“根集合” 就是一组必须活跃的引用

基本思路就是通过一系列名为 GC Roots的对象作为起始点,从这个被称为GC Roots的对象开始向下搜索,如果一个对象到GC Roots没有任何引用链相连,则说明此对象不可用。也即给定一个集合的引用作为根出发,通过引用关系遍历对象图,能被遍历到的(可到达的)对象就被判定为存活,没有被遍历到的对象就被判定为死亡

image-20200319114526625

必须从GC Roots对象开始,这个类似于linux的 / 也就是根目录

蓝色部分是从GC Roots出发,能够循环可达

而白色部分,从GC Roots出发,无法到达

2.3 一句话理解GC Roots

假设我们现在有三个实体,分别是 人,狗,毛衣

然后他们之间的关系是:人 牵着 狗,狗穿着毛衣,他们之间是强连接的关系

有一天人消失了,只剩下狗狗 和 毛衣,这个时候,把人想象成 GC Roots,因为 人 和 狗之间失去了绳子连接,

那么狗可能被回收,也就是被警察抓起来,被送到流浪狗寄养所

假设狗和人有强连接的时候,狗狗就不会被当成是流浪狗

2.4 那些对象可以当做GC Roots
  • 虚拟机栈(栈帧中的局部变量区,也叫做局部变量表)中的引用对象
  • 方法区中的类静态属性引用的对象
  • 方法区中常量引用的对象
  • 本地方法栈中的JNI(Native方法)的引用对象
2.5 代码说明

/**
 * 在Java中,可以作为GC Roots的对象有:
 * - 虚拟机栈(栈帧中的局部变量区,也叫做局部变量表)中的引用对象
 * - 方法区中的类静态属性引用的对象
 * - 方法区中常量引用的对象
 * - 本地方法栈中的JNI(Native方法)的引用对象
 */
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();
    }
}

2.2 JVM参数调优

1 前言

你说你做过JVM调优和参数配置,请问如何盘点查看JVM系统默认值

使用jps和jinfo进行查看

-Xms:初始堆空间
-Xmx:堆最大值
-Xss:栈空间

-Xms 和 -Xmx最好调整一致,防止JVM频繁进行收集和回收

2 JVM参数类型

  • 标配参数(从JDK1.0 - Java12都在,很稳定)
    • -version
    • -help
    • java -showversion
  • X参数(了解)
    • -Xint:解释执行
    • -Xcomp:第一次使用就编译成本地代码
    • -Xmixed:混合模式
  • XX参数(重点)
    • Boolean类型
      • 公式:-XX:+ 或者-某个属性 + 表示开启,-表示关闭
      • Case:-XX:-PrintGCDetails:表示关闭了GC详情输出
    • key-value类型
      • 公式:-XX:属性key=属性value
      • 不满意初始值,可以通过下列命令调整
      • case:如何:-XX:MetaspaceSize=21807104:查看Java元空间的值

3 查看运行的Java程序,JVM参数是否开启,具体值为多少?

首先我们运行一个HelloGC的java程序

public class HelloGC {

    public static void main(String[] args) throws InterruptedException {
        System.out.println("hello GC");
        Thread.sleep(Integer.MAX_VALUE);
    }
}

然后使用下列命令查看它的默认参数

jps:查看java的后台进程
jinfo:查看正在运行的java程序

具体使用:

jps -l得到进程号
12608 com.moxi.interview.study.GC.HelloGC
15200 sun.tools.jps.Jps
15296 org.jetbrains.idea.maven.server.RemoteMavenServer36
4528
12216 org.jetbrains.jps.cmdline.Launcher
9772 org.jetbrains.kotlin.daemon.KotlinCompileDaemon

查看到HelloGC的进程号为:12608

我们使用jinfo -flag 然后查看是否开启PrintGCDetails这个参数

jinfo -flag PrintGCDetails 12608

得到的内容为

-XX:-PrintGCDetails

上面提到了,-号表示关闭,即没有开启PrintGCDetails这个参数

下面我们需要在启动HelloGC的时候,增加 PrintGCDetails这个参数,需要在运行程序的时候配置JVM参数

image-20200319122922264

然后在VM Options中加入下面的代码,现在+号表示开启

-XX:+PrintGCDetails

然后在使用jinfo查看我们的配置

jps -l
jinfo -flag PrintGCDetails 13540

得到的结果为

-XX:+PrintGCDetails

我们看到原来的-号变成了+号,说明我们通过 VM Options配置的JVM参数已经生效了

使用下列命令,会把jvm的全部默认参数输出

jinfo -flags ***

4 题外话(坑题)

两个经典参数:-Xms 和 -Xmx,这两个参数 如何解释

这两个参数,还是属于XX参数,因为取了别名

  • -Xms 等价于 -XX:InitialHeapSize :初始化堆内存(默认只会用最大物理内存的64分1)
  • -Xmx 等价于 -XX:MaxHeapSize :最大堆内存(默认只会用最大物理内存的4分1)

5 查看JVM默认参数

  • -XX:+PrintFlagsInitial

    • 主要是查看初始默认值
    • 公式
      • java -XX:+PrintFlagsInitial -version
      • java -XX:+PrintFlagsInitial(重要参数)

    image-20200320212256284

  • -XX:+PrintFlagsFinal:表示修改以后,最终的值

    会将JVM的各个结果都进行打印
    
    如果有  := 表示修改过的, = 表示没有修改过的

6 工作中常用的JVM基本配置参数

image-20200322163252777

6.1 查看堆内存

查看JVM的初始化堆内存 -Xms 和最大堆内存 Xmx

public class HelloGC {

    public static void main(String[] args) throws InterruptedException {
        // 返回Java虚拟机中内存的总量
        long totalMemory = Runtime.getRuntime().totalMemory();

        // 返回Java虚拟机中试图使用的最大内存量
        long maxMemory = Runtime.getRuntime().maxMemory();

        System.out.println("TOTAL_MEMORY(-Xms) = " + totalMemory + "(字节)、" + (totalMemory / (double)1024 / 1024) + "MB");
        System.out.println("MAX_MEMORY(-Xmx) = " + maxMemory + "(字节)、" + (maxMemory / (double)1024 / 1024) + "MB");

    }
}

运行结果为:

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

-Xms 初始堆内存为:物理内存的1/64 -Xmx 最大堆内存为:系统物理内存的 1/4

6.2 打印JVM默认参数

使用 -XX:+PrintCommandLineFlags 打印出JVM的默认的简单初始化参数

比如我的机器输出为:

-XX:InitialHeapSize=266376000 -XX:MaxHeapSize=4262016000 -XX:+PrintCommandLineFlags -XX:+UseCompressedClassPointers -XX:+UseCompressedOops -XX:-UseLargePagesIndividualAllocation -XX:+UseParallelGC
6.3 生活常用调优参数
  • -Xms:初始化堆内存,默认为物理内存的1/64,等价于 -XX:initialHeapSize
  • -Xmx:最大堆内存,默认为物理内存的1/4,等价于-XX:MaxHeapSize
  • -Xss:设计单个线程栈的大小,一般默认为512K~1024K,等价于 -XX:ThreadStackSize
    • 使用 jinfo -flag ThreadStackSize 会发现 -XX:ThreadStackSize = 0
    • 这个值的大小是取决于平台的
    • Linux/x64:1024KB
    • OS X:1024KB
    • Oracle Solaris:1024KB
    • Windows:取决于虚拟内存的大小
  • -Xmn:设置年轻代大小
  • -XX:MetaspaceSize:设置元空间大小
    • 元空间的本质和永久代类似,都是对JVM规范中方法区的实现,不过元空间与永久代之间最大的区别在于:元空间并不在虚拟机中,而是使用本地内存,因此,默认情况下,元空间的大小仅受本地内存限制。
    • -Xms10m -Xmx10m -XX:MetaspaceSize=1024m -XX:+PrintFlagsFinal
    • 但是默认的元空间大小:只有20多M
    • 为了防止在频繁的实例化对象的时候,让元空间出现OOM,因此可以把元空间设置的大一些
  • -XX:PrintGCDetails:输出详细GC收集日志信息
    • GC
    • Full GC

GC日志收集流程图

image-20200322185639902

我们使用一段代码,制造出垃圾回收的过程

首先我们设置一下程序的启动配置: 设置初始堆内存为10M,最大堆内存为10M

-Xms10m -Xmx10m -XX:+PrintGCDetails

然后用下列代码,创建一个 非常大空间的byte类型数组

byte [] byteArray = new byte[50 * 1024 * 1024];

运行后,发现会出现下列错误,这就是OOM:java内存溢出,也就是堆空间不足

Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
	at com.moxi.interview.study.GC.HelloGC.main(HelloGC.java:22)

同时还打印出了GC垃圾回收时候的详情

[GC (Allocation Failure) [PSYoungGen: 1972K->504K(2560K)] 1972K->740K(9728K), 0.0156109 secs] [Times: user=0.00 sys=0.00, real=0.03 secs] 
[GC (Allocation Failure) [PSYoungGen: 504K->480K(2560K)] 740K->772K(9728K), 0.0007815 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
[Full GC (Allocation Failure) [PSYoungGen: 480K->0K(2560K)] [ParOldGen: 292K->648K(7168K)] 772K->648K(9728K), [Metaspace: 3467K->3467K(1056768K)], 0.0080505 secs] [Times: user=0.00 sys=0.00, real=0.01 secs] 
[GC (Allocation Failure) [PSYoungGen: 0K->0K(2560K)] 648K->648K(9728K), 0.0003035 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
[Full GC (Allocation Failure) [PSYoungGen: 0K->0K(2560K)] [ParOldGen: 648K->630K(7168K)] 648K->630K(9728K), [Metaspace: 3467K->3467K(1056768K)], 0.0058502 secs] [Times: user=0.00 sys=0.00, real=0.01 secs] 
Heap
 PSYoungGen      total 2560K, used 80K [0x00000000ffd00000, 0x0000000100000000, 0x0000000100000000)
  eden space 2048K, 3% used [0x00000000ffd00000,0x00000000ffd143d8,0x00000000fff00000)
  from space 512K, 0% used [0x00000000fff00000,0x00000000fff00000,0x00000000fff80000)
  to   space 512K, 0% used [0x00000000fff80000,0x00000000fff80000,0x0000000100000000)
 ParOldGen       total 7168K, used 630K [0x00000000ff600000, 0x00000000ffd00000, 0x00000000ffd00000)
  object space 7168K, 8% used [0x00000000ff600000,0x00000000ff69dbd0,0x00000000ffd00000)
 Metaspace       used 3510K, capacity 4500K, committed 4864K, reserved 1056768K
  class space    used 389K, capacity 392K, committed 512K, reserved 1048576K

问题发生的原因:

因为们通过 -Xms10m 和 -Xmx10m 只给Java堆栈设置了10M的空间,但是创建了50M的对象,因此就会出现空间不足,而导致出错

同时在垃圾收集的时候,我们看到有两个对象:GC 和 Full GC

6.4 GC垃圾收集

GC在新生区

[GC (Allocation Failure) [PSYoungGen: 1972K->504K(2560K)] 1972K->740K(9728K), 0.0156109 secs] [Times: user=0.00 sys=0.00, real=0.03 secs]

GC (Allocation Failure):表示分配失败,那么就需要触发年轻代空间中的内容被回收

[PSYoungGen: 1972K->504K(2560K)] 1972K->740K(9728K), 0.0156109 secs]

参数对应的图为:

image-20200323124000865

6.6 Full GC垃圾回收

Full GC大部分发生在养老区

[Full GC (Allocation Failure) [PSYoungGen: 0K->0K(2560K)] [ParOldGen: 648K->630K(7168K)] 648K->630K(9728K), [Metaspace: 3467K->3467K(1056768K)], 0.0058502 secs] [Times: user=0.00 sys=0.00, real=0.01 secs]

image-20200323125839653

规律:

[名称: GC前内存占用 -> GC后内存占用 (该区内存总大小)]

当我们出现了老年代都扛不住的时候,就会出现OOM异常

6.7 -XX: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相同

Java堆从GC的角度还可以细分为:新生代(Eden区,From Survivor区合To Survivor区)和老年代

image-20200323130442088

  • eden、SurvivorFrom复制到SurvivorTo,年龄 + 1

首先,当Eden区满的时候会触发第一次GC,把还活着的对象拷贝到SurvivorFrom去,当Eden区再次触发GC的时候会扫描Eden区合From区域,对这两个区域进行垃圾回收,经过这次回收后还存活的对象,则直接复制到To区域(如果对象的年龄已经到达老年的标准,则赋值到老年代区),通知把这些对象的年龄 + 1

  • 清空eden、SurvivorFrom

然后,清空eden,SurvivorFrom中的对象,也即复制之后有交换,谁空谁是to

  • SurvivorTo和SurvivorFrom互换

最后,SurvivorTo和SurvivorFrom互换,原SurvivorTo成为下一次GC时的SurvivorFrom区,部分对象会在From和To区域中复制来复制去,如此交换15次(由JVM参数MaxTenuringThreshold决定,这个参数默认为15),最终如果还是存活,就存入老年代

image-20200323150946414

6.8 -XX:NewRatio(了解)

配置年轻代new 和老年代old 在堆结构的占比

默认: -XX:NewRatio=2 新生代占1,老年代2,年轻代占整个堆的1/3

-XX:NewRatio=4:新生代占1,老年代占4,年轻代占整个堆的1/5,NewRadio值就是设置老年代的占比,剩下的1个新生代

新生代特别小,会造成频繁的进行GC收集

6.9 -XX:MaxTenuringThreshold

设置垃圾最大年龄,SurvivorTo和SurvivorFrom互换,原SurvivorTo成为下一次GC时的SurvivorFrom区,部分对象会在From和To区域中复制来复制去,如此交换15次(由JVM参数MaxTenuringThreshold决定,这个参数默认为15),最终如果还是存活,就存入老年代

这里就是调整这个次数的,默认是15,并且设置的值 在 0~15之间

查看默认进入老年代年龄:jinfo -flag MaxTenuringThreshold 17344

-XX:MaxTenuringThreshold=0:设置垃圾最大年龄。如果设置为0的话,则年轻对象不经过Survivor区,直接进入老年代。对于年老代比较多的应用,可以提高效率。如果将此值设置为一个较大的值,则年轻代对象会在Survivor区进行多次复制,这样可以增加对象再年轻代的存活时间,增加在年轻代即被回收的概念

2.3 Java中的引用

1 前言

在原来的时候,我们谈到一个类的实例化

Person p = new Person()

在等号的左边,就是一个对象的引用,存储在栈中

而等号右边,就是实例化的对象,存储在堆中

其实这样的一个引用关系,就被称为强引用

2 整体架构

image-20200323155120778

3 强引用

当内存不足的时候,JVM开始垃圾回收,对于强引用的对象,就算是出现了OOM也不会对该对象进行回收,打死也不回收~!

强引用是我们最常见的普通对象引用,只要还有一个强引用指向一个对象,就能表明对象还“活着”,垃圾收集器不会碰这种对象。在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);
    }
}

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

null
java.lang.Object@14ae5a5

4 软引用

软引用是一种相对弱化了一些的引用,需要用Java.lang.ref.SoftReference类来实现,可以让对象豁免一些垃圾收集,对于只有软引用的对象来讲:

  • 当系统内存充足时,它不会被回收
  • 当系统内存不足时,它会被回收

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

具体使用

/**
 * 软引用
 */
public class SoftReferenceDemo {

    /**
     * 内存够用的时候
     */
    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@14ae5a5
java.lang.Object@14ae5a5

[GC (System.gc()) [PSYoungGen: 1396K->504K(1536K)] 1504K->732K(5632K), 0.0007842 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
[Full GC (System.gc()) [PSYoungGen: 504K->0K(1536K)] [ParOldGen: 228K->651K(4096K)] 732K->651K(5632K), [Metaspace: 3480K->3480K(1056768K)], 0.0058450 secs] [Times: user=0.00 sys=0.00, real=0.01 secs] 

null
java.lang.Object@14ae5a5

下面我们看当内存不够的时候,我们使用了JVM启动参数配置,给初始化堆内存为5M

-Xms5m -Xmx5m -XX:+PrintGCDetails

但是在创建对象的时候,我们创建了一个30M的大对象

// 创建30M的大对象
byte[] bytes = new byte[30 * 1024 * 1024];

这就必然会触发垃圾回收机制,这也是中间出现的垃圾回收过程,最后看结果我们发现,o1 和 softReference都被回收了,因此说明,软引用在内存不足的时候,会自动回收

java.lang.Object@7f31245a
java.lang.Object@7f31245a

[GC (Allocation Failure) [PSYoungGen: 31K->160K(1536K)] 682K->811K(5632K), 0.0003603 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
[GC (Allocation Failure) [PSYoungGen: 160K->96K(1536K)] 811K->747K(5632K), 0.0006385 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
[Full GC (Allocation Failure) [PSYoungGen: 96K->0K(1536K)] [ParOldGen: 651K->646K(4096K)] 747K->646K(5632K), [Metaspace: 3488K->3488K(1056768K)], 0.0067976 secs] [Times: user=0.02 sys=0.00, real=0.01 secs] 
[GC (Allocation Failure) [PSYoungGen: 0K->0K(1536K)] 646K->646K(5632K), 0.0004024 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
[Full GC (Allocation Failure) [PSYoungGen: 0K->0K(1536K)] [ParOldGen: 646K->627K(4096K)] 646K->627K(5632K), [Metaspace: 3488K->3488K(1056768K)], 0.0065506 secs] [Times: user=0.00 sys=0.00, real=0.01 secs] 

null
null

5 弱引用

不管内存是否够,只要有GC操作就会进行回收

弱引用需要用 java.lang.ref.WeakReference 类来实现,它比软引用生存期更短

对于只有弱引用的对象来说,只要垃圾回收机制一运行,不管JVM的内存空间是否足够,都会回收该对象占用的空间。

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

我们看结果,能够发现,我们并没有制造出OOM内存溢出,而只是调用了一下GC操作,垃圾回收就把它给收集了

java.lang.Object@14ae5a5
java.lang.Object@14ae5a5

[GC (System.gc()) [PSYoungGen: 5246K->808K(76288K)] 5246K->816K(251392K), 0.0008236 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
[Full GC (System.gc()) [PSYoungGen: 808K->0K(76288K)] [ParOldGen: 8K->675K(175104K)] 816K->675K(251392K), [Metaspace: 3494K->3494K(1056768K)], 0.0035953 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 

null
null

6 软引用和弱引用的使用场景

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

  • 如果每次读取图片都从硬盘读取则会严重影响性能
  • 如果一次性全部加载到内存中,又可能造成内存溢出

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

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

Map<String, SoftReference<String>> imageCache = new HashMap<String, SoftReference<Bitmap>>();
WeakHashMap是什么?

比如一些常常和底层打交道的,mybatis等,底层都应用到了WeakHashMap

WeakHashMap和HashMap类似,只不过它的Key是使用了弱引用的,也就是说,当执行GC的时候,HashMap中的key会进行回收,下面我们使用例子来测试一下

我们使用了两个方法,一个是普通的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;

    System.gc();

    System.out.println(map);
}

第二个是使用了WeakHashMap,完整代码如下

/**
 * WeakHashMap
 */
public class WeakHashMapDemo {
    public static void main(String[] args) {
        myHashMap();
        System.out.println("==========");
        myWeakHashMap();
    }

    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;

        System.gc();

        System.out.println(map);
    }

    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=HashMap}
{1=HashMap}
==========
{1=WeakHashMap}
{}

从这里我们看到,对于普通的HashMap来说,key置空并不会影响,HashMap的键值对,因为这个属于强引用,不会被垃圾回收。

但是WeakHashMap,在进行GC操作后,弱引用的就会被回收

7 虚引用

7.1 概念

虚引用又称为幽灵引用,需要java.lang.ref.PhantomReference 类来实现

顾名思义,就是形同虚设,与其他几种引用都不同,虚引用并不会决定对象的生命周期。

如果一个对象持有虚引用,那么它就和没有任何引用一样,在任何时候都可能被垃圾回收器回收,它不能单独使用也不能通过它访问对象,虚引用必须和引用队列ReferenceQueue联合使用。

虚引用的主要作用和跟踪对象被垃圾回收的状态,仅仅是提供一种确保对象被finalize以后,做某些事情的机制。

PhantomReference的get方法总是返回null,因此无法访问对象的引用对象。其意义在于说明一个对象已经进入finalization阶段,可以被gc回收,用来实现比finalization机制更灵活的回收操作

换句话说,设置虚引用关联的唯一目的,就是在这个对象被收集器回收的时候,收到一个系统通知或者后续添加进一步的处理,Java技术允许使用finalize()方法在垃圾收集器将对象从内存中清除出去之前,做必要的清理工作

这个就相当于Spring AOP里面的后置通知

7.2 场景

一般用于在回收时候做通知相关操作

8 引用队列 ReferenceQueue

软引用,弱引用,虚引用在回收之前,需要在引用队列保存一下

我们在初始化的弱引用或者虚引用的时候,可以传入一个引用队列

Object o1 = new Object();

// 创建引用队列
ReferenceQueue<Object> referenceQueue = new ReferenceQueue<>();

// 创建一个弱引用
WeakReference<Object> weakReference = new WeakReference<>(o1, referenceQueue);

那么在进行GC回收的时候,弱引用和虚引用的对象都会被回收,但是在回收之前,它会被送至引用队列中

完整代码如下:

/**
 * 虚引用
 *
 */
public class PhantomReferenceDemo {

    public static void main(String[] args) {

        Object o1 = new Object();

        // 创建引用队列
        ReferenceQueue<Object> referenceQueue = new ReferenceQueue<>();

        // 创建一个弱引用
        WeakReference<Object> weakReference = new WeakReference<>(o1, referenceQueue);

        // 创建一个弱引用
//        PhantomReference<Object> weakReference = new PhantomReference<>(o1, referenceQueue);

        System.out.println(o1);
        System.out.println(weakReference.get());
        // 取队列中的内容
        System.out.println(referenceQueue.poll());

        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@14ae5a5
java.lang.Object@14ae5a5
null
执行GC操作
null
null
java.lang.ref.WeakReference@7f3124

从这里我们能看到,在进行垃圾回收后,我们弱引用对象,也被设置成null,但是在队列中还能够导出该引用的实例,这就说明在回收之前,该弱引用的实例被放置引用队列中了,我们可以通过引用队列进行一些后置操作

9 GCRoots和四大引用小总结

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

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

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

image-20200324123829937

2.4 Java内存溢出OOM

1 经典错误

JVM中常见的两个错误

StackoverFlowError :栈溢出

OutofMemoryError: java heap space:堆溢出

除此之外,还有以下的错误

  • java.lang.StackOverflowError
  • 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

2 架构

OutOfMemoryError和StackOverflowError是属于Error,不是Exception

image-20200324144802828

3 StackoverFlowError

堆栈溢出,我们有最简单的一个递归调用,就会造成堆栈溢出,也就是深度的方法调用

栈一般是512K,不断的深度调用,直到栈被撑破

public class StackOverflowErrorDemo {

    public static void main(String[] args) {
        stackOverflowError();
    }
    /**
     * 栈一般是512K,不断的深度调用,直到栈被撑破
     * Exception in thread "main" java.lang.StackOverflowError
     */
    private static void stackOverflowError() {
        stackOverflowError();
    }
}

运行结果

Exception in thread "main" java.lang.StackOverflowError
	at com.moxi.interview.study.oom.StackOverflowErrorDemo.stackOverflowError(StackOverflowErrorDemo.java:17)

4 OutOfMemoryError

4.1 java heap space

创建了很多对象,导致堆空间不够存储

/**
 * Java堆内存不足
 */
public class JavaHeapSpaceDemo {
    public static void main(String[] args) {

        // 堆空间的大小 -Xms10m -Xmx10m
        // 创建一个 80M的字节数组
        byte [] bytes = new byte[80 * 1024 * 1024];
    }
}

我们创建一个80M的数组,会直接出现Java heap space

Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
4.2 GC overhead limit exceeded

GC回收时间过长时会抛出OutOfMemoryError,过长的定义是,超过了98%的时间用来做GC,并且回收了不到2%的堆内存

连续多次GC都只回收了不到2%的极端情况下,才会抛出。假设不抛出GC overhead limit 错误会造成什么情况呢?

那就是GC清理的这点内存很快会再次被填满,迫使GC再次执行,这样就形成了恶性循环,CPU的使用率一直都是100%,而GC却没有任何成果。

image-20200324150646260

代码演示:

为了更快的达到效果,我们首先需要设置JVM启动参数

-Xms10m -Xmx10m -XX:+PrintGCDetails -XX:MaxDirectMemorySize=5m

这个异常出现的步骤就是,我们不断的像list中插入String对象,直到启动GC回收

/**
 * GC 回收超时
 * JVM参数配置: -Xms10m -Xmx10m -XX:+PrintGCDetails -XX:MaxDirectMemorySize=5m
 */
public class GCOverheadLimitDemo {
    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;
        } finally {

        }

    }
}

运行结果

[Full GC (Ergonomics) [PSYoungGen: 2047K->2047K(2560K)] [ParOldGen: 7106K->7106K(7168K)] 9154K->9154K(9728K), [Metaspace: 3504K->3504K(1056768K)], 0.0311093 secs] [Times: user=0.13 sys=0.00, real=0.03 secs] 
[Full GC (Ergonomics) [PSYoungGen: 2047K->0K(2560K)] [ParOldGen: 7136K->667K(7168K)] 9184K->667K(9728K), [Metaspace: 3540K->3540K(1056768K)], 0.0058093 secs] [Times: user=0.00 sys=0.00, real=0.01 secs] 
Heap
 PSYoungGen      total 2560K, used 114K [0x00000000ffd00000, 0x0000000100000000, 0x0000000100000000)
  eden space 2048K, 5% used [0x00000000ffd00000,0x00000000ffd1c878,0x00000000fff00000)
  from space 512K, 0% used [0x00000000fff80000,0x00000000fff80000,0x0000000100000000)
  to   space 512K, 0% used [0x00000000fff00000,0x00000000fff00000,0x00000000fff80000)
 ParOldGen       total 7168K, used 667K [0x00000000ff600000, 0x00000000ffd00000, 0x00000000ffd00000)
  object space 7168K, 9% used [0x00000000ff600000,0x00000000ff6a6ff8,0x00000000ffd00000)
 Metaspace       used 3605K, capacity 4540K, committed 4864K, reserved 1056768K
  class space    used 399K, capacity 428K, committed 512K, reserved 1048576K
  
 
Exception in thread "main" java.lang.OutOfMemoryError: GC overhead limit exceeded
	at java.lang.Integer.toString(Integer.java:403)
	at java.lang.String.valueOf(String.java:3099)
	at com.moxi.interview.study.oom.GCOverheadLimitDemo.main(GCOverheadLimitDemo.java:18)

我们能够看到 多次Full GC,并没有清理出空间,在多次执行GC操作后,就抛出异常 GC overhead limit

4.3 Direct buffer memory

Netty + NIO:这是由于NIO引起的

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

ByteBuffer.allocate(capability):第一种方式是分配JVM堆内存,属于GC管辖范围,由于需要拷贝所以速度相对较慢

ByteBuffer.allocteDirect(capability):第二种方式是分配OS本地内存,不属于GC管辖范围,由于不需要内存的拷贝,所以速度相对较快

但如果不断分配本地内存,堆内存很少使用,那么JVM就不需要执行GC,DirectByteBuffer对象就不会被回收,这时候怼内存充足,但本地内存可能已经使用光了,再次尝试分配本地内存就会出现OutOfMemoryError,那么程序就奔溃了。

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

我们使用 -XX:MaxDirectMemorySize=5m 配置能使用的堆外物理内存为5M

-Xms10m -Xmx10m -XX:+PrintGCDetails -XX:MaxDirectMemorySize=5m

然后我们申请一个6M的空间

// 只设置了5M的物理内存使用,但是却分配 6M的空间
ByteBuffer bb = ByteBuffer.allocateDirect(6 * 1024 * 1024);

这个时候,运行就会出现问题了

配置的maxDirectMemory:5.0MB
[GC (System.gc()) [PSYoungGen: 2030K->488K(2560K)] 2030K->796K(9728K), 0.0008326 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
[Full GC (System.gc()) [PSYoungGen: 488K->0K(2560K)] [ParOldGen: 308K->712K(7168K)] 796K->712K(9728K), [Metaspace: 3512K->3512K(1056768K)], 0.0052052 secs] [Times: user=0.09 sys=0.00, real=0.00 secs] 
Exception in thread "main" java.lang.OutOfMemoryError: Direct buffer memory
	at java.nio.Bits.reserveMemory(Bits.java:693)
	at java.nio.DirectByteBuffer.<init>(DirectByteBuffer.java:123)
	at java.nio.ByteBuffer.allocateDirect(ByteBuffer.java:311)
	at com.moxi.interview.study.oom.DIrectBufferMemoryDemo.main(DIrectBufferMemoryDemo.java:19)
4.4 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 UnableCreateNewThreadDemo {
    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();
        }
    }
}

这个时候,就会出现下列的错误,线程数大概在 900多个

Exception in thread "main" java.lang.OutOfMemoryError: unable to cerate new native thread

如何查看线程数

ulimit -u
4.5 Metaspace

元空间内存不足,Matespace元空间应用的是本地内存

-XX:MetaspaceSize 的处理化大小为20M

元空间是什么

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

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

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

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

模拟Metaspace空间溢出,我们不断生成类 往元空间里灌输,类占据的空间总会超过Metaspace指定的空间大小

代码

在模拟异常生成时候,因为初始化的元空间为20M,因此我们使用JVM参数调整元空间的大小,为了更好的效果

-XX:MetaspaceSize=8m -XX:MaxMetaspaceSize=8m

代码如下:

/**
 * 元空间溢出
 *
 */
public class MetaspaceOutOfMemoryDemo {

    // 静态类
    static class OOMTest {

    }

    public static void main(final String[] args) {
        // 模拟计数多少次以后发生异常
        int i =0;
        try {
            while (true) {
                i++;
                // 使用Spring的动态字节码技术
                Enhancer enhancer = new Enhancer();
                enhancer.setSuperclass(OOMTest.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);
                    }
                });
            }
        } catch (Exception e) {
            System.out.println("发生异常的次数:" + i);
            e.printStackTrace();
        } finally {

        }

    }
}

会出现以下错误:

发生异常的次数: 201
java.lang.OutOfMemoryError:Metaspace

2.5 垃圾收集器

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

天上飞的理念,要有落地的实现(垃圾收集器就是GC垃圾回收算法的实现)

GC算法是内存回收的方法论,垃圾收集器就是算法的落地实现

GC算法主要有以下几种

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

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

2 四种主要的垃圾收集器

  • Serial:串行回收 -XX:+UseSeriallGC
  • Parallel:并行回收 -XX:+UseParallelGC
  • CMS:并发标记清除
  • G1
  • ZGC:(java 11 出现的)

image-20200325084453631

2.1 Serial

串行垃圾回收器,它为单线程环境设计且值使用一个线程进行垃圾收集,会暂停所有的用户线程,只有当垃圾回收完成时,才会重新唤醒主线程继续执行。所以不适合服务器环境

image-20200325085320683

2.2 Parallel

并行垃圾收集器,多个垃圾收集线程并行工作,此时用户线程也是阻塞的,适用于科学计算 / 大数据处理等弱交互场景,也就是说Serial 和 Parallel其实是类似的,不过是多了几个线程进行垃圾收集,但是主线程都会被暂停,但是并行垃圾收集器处理时间,肯定比串行的垃圾收集器要更短

image-20200325085729428

2.3 CMS

并发标记清除,用户线程和垃圾收集线程同时执行(不一定是并行,可能是交替执行),不需要停顿用户线程,互联网公司都在使用,适用于响应时间有要求的场景。并发是可以有交互的,也就是说可以一边进行收集,一边执行应用程序。

image-20200325090858921

2.4 G1

G1垃圾回收器将堆内存分割成不同区域,然后并发的进行垃圾回收

image-20200325093222711

3 垃圾收集器总结

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

image-20200325091619082

4 查看默认垃圾收集器

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

-XX:+PrintCommandLineFlags

然后运行一个程序后,能够看到它的一些初始配置信息

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

移动到最后一句,就能看到 -XX:+UseParallelGC 说明使用的是并行垃圾回收

-XX:+UseParallelGC

5 默认垃圾收集器有哪些

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

  • UserSerialGC:串行垃圾收集器
  • UserParallelGC:并行垃圾收集器
  • UseConcMarkSweepGC:(CMS)并发标记清除
  • UseParNewGC:年轻代的并行垃圾回收器
  • UseParallelOldGC:老年代的并行垃圾回收器
  • UseG1GC:G1垃圾收集器
  • UserSerialOldGC:串行老年代垃圾收集器(已经被移除)

底层源码

image-20200325100653829

6 各垃圾收集器的使用范围

image-20200325101451849

新生代使用的:

  • Serial Copying: UserSerialGC,串行垃圾回收器
  • Parallel Scavenge:UserParallelGC,并行垃圾收集器
  • ParNew:UserParNewGC,新生代并行垃圾收集器

老年区使用的:

  • Serial Old:UseSerialOldGC,老年代串行垃圾收集器
  • Parallel Compacting(Parallel Old):UseParallelOldGC,老年代并行垃圾收集器
  • CMS:UseConcMarkSwepp,并行标记清除垃圾收集器

各区都能使用的:

G1:UseG1GC,G1垃圾收集器

垃圾收集器就来具体实现这些GC算法并实现内存回收,不同厂商,不同版本的虚拟机实现差别很大,HotSpot中包含的收集器如下图所示:

image-20200325102329216

7 部分参数说明

  • DefNew:Default New Generation
  • Tenured:Old
  • ParNew:Parallel New Generation
  • PSYoungGen:Parallel Scavenge
  • ParOldGen:Parallel Old Generation

8 Java中的Server和Client模式

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

操作系统

  • 32位的Window操作系统,不论硬件如何都默认使用Client的JVM模式
  • 32位的其它操作系统,2G内存同时有2个cpu以上用Server模式,低于该配置还是Client模式
  • 64位只有Server模式

image-20200325175208231

9 新生代下的垃圾收集器

9.1 串行GC(Serial)

串行GC(Serial)(Serial Copying)

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

image-20200325175704604

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

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

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

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

-Xms10m -Xmx10m -XX:PrintGCDetails -XX:+PrintConmandLineFlags -XX:+UseSerialGC
9.2 并行GC(ParNew)

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

image-20200325191328733

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

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

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

-Xms10m -Xmx10m -XX:PrintGCDetails -XX:+PrintConmandLineFlags -XX:+UseParNewGC

但是会出现警告,即 ParNew 和 Serial Old 这样搭配,Java8已经不再被推荐

image-20200325194316660

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

9.3 并行回收GC(Parallel)/ (Parallel Scavenge)

因为Serial 和 ParNew都不推荐使用了,因此现在新生代默认使用的是Parallel Scavenge,也就是新生代和老年代都是使用并行

image-20200325204437678

Parallel Scavenge收集器类似ParNew也是一个新生代垃圾收集器,使用复制算法,也是一个并行的多线程的垃圾收集器,俗称吞吐量优先收集器。一句话:串行收集器在新生代和老年代的并行化

它关注的重点是:

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

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

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

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

-Xms10m -Xmx10m -XX:PrintGCDetails -XX:+PrintConmandLineFlags -XX:+UseParallelGC

10 老年代下的垃圾收集器

10.1 串行GC(Serial Old) / (Serial MSC)

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

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

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

配置方法:

-Xms10m -Xmx10m -XX:PrintGCDetails -XX:+PrintConmandLineFlags -XX:+UseSerialOldlGC

该垃圾收集器,目前已经不推荐使用了

10.2 并行GC(Parallel Old)/ (Parallel MSC)

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收集器,设置该参数后,新生代Parallel+老年代 Parallel Old

image-20200325211525152

使用老年代并行收集器:

-Xms10m -Xmx10m -XX:PrintGCDetails -XX:+PrintConmandLineFlags -XX:+UseParallelOldlGC
10.3 并发标记清除GC(CMS)

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

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

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

image-20200325212836441

Concurrent Mark Sweep:并发标记清除,并发收集低停顿,并发指的是与用户线程一起执行

开启该收集器的JVM参数: -XX:+UseConcMarkSweepGC 开启该参数后,会自动将 -XX:+UseParNewGC打开,开启该参数后,使用ParNew(young 区用)+ CMS(Old 区用) + Serial Old 的收集器组合,Serial Old将作为CMS出错的后备收集器

-Xms10m -Xmx10m -XX:+PrintGCDetails -XX:+UseConcMarkSweepGC
四个步骤
  • 初始标记(CMS initial mark)
    • 只是标记一个GC Roots 能直接关联的对象,速度很快,仍然需要暂停所有的工作线程
  • 并发标记(CMS concurrent mark)和用户线程一起
    • 进行GC Roots跟踪过程,和用户线程一起工作,不需要暂停工作线程。主要标记过程,标记全部对象
  • 重新标记(CMS remark)
    • 为了修正在并发标记期间,因用户程序继续运行而导致标记产生变动的那一部分对象的标记记录,仍然需要暂停所有的工作线程,由于并发标记时,用户线程依然运行,因此在正式清理前,在做修正
  • 并发清除(CMS concurrent sweep)和用户线程一起
    • 清除GC Roots不可达对象,和用户线程一起工作,不需要暂停工作线程。基于标记结果,直接清理对象,由于耗时最长的并发标记和并发清除过程中,垃圾收集线程可以和用户现在一起并发工作,所以总体上来看CMS收集器的内存回收和用户线程是一起并发地执行。

image-20200325215401981

优点:并发收集低停顿

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

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

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

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

11.1 新生代使用复制算法

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

11.2 老年代采用标记整理

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

12 垃圾收集器如何选择

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

13 G1垃圾收集器

image-20200326115120405

13.1 开启G1垃圾收集器
-XX:+UseG1GC
13.2 以前收集器的特点
  • 年轻代和老年代是各自独立且连续的内存块
  • 年轻代收集使用单eden + S0 + S1 进行复制算法
  • 老年代收集必须扫描珍整个老年代区域
  • 都是以尽可能少而快速地执行GC为设计原则
13.3 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内存区域。

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

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

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

核心思想是将整个堆内存区域分成大小相同的子区域(Region),在JVM启动时会自动设置子区域大小

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

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

Region区域化垃圾收集器

13.6 Region区域化垃圾收集器

G1将新生代、老年代的物理空间划分取消了

image-20200326120105859

同时对内存进行了区域划分

image-20200326120130427

G1算法将堆划分为若干个区域(Reign),它仍然属于分代收集器,这些Region的一部分包含新生代,新生代的垃圾收集依然采用暂停所有应用线程的方式,将存活对象拷贝到老年代或者Survivor空间

这些Region的一部分包含老年代,G1收集器通过将对象从一个区域复制到另外一个区域,完成了清理工作。这就意味着,在正常的处理过程中,G1完成了堆的压缩(至少是部分堆的压缩),这样也就不会有CMS内存碎片的问题存在了。

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

13.7 回收步骤

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

  • Eden区的数据移动到Survivor区,加入出现Survivor区空间不够,Eden区数据会晋升到Old区
  • Survivor区的数据移动到新的Survivor区,部分数据晋升到Old区
  • 最后Eden区收拾干净了,GC结束,用户的应用程序继续执行

image-20200326121409237

回收完成后

image-20200326121622208

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

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

image-20200326121914326

13.9 参数配置

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

三步归纳:-XX:+UseG1GC -Xmx32G -XX:MaxGCPauseMillis=100

-XX:MaxGCPauseMillis=n:最大GC停顿时间单位毫秒,这是个软目标,JVM尽可能停顿小于这个时间

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

14SpringBoot结合JVMGC

启动微服务时候,就可以带上JVM和GC的参数

  • IDEA开发完微服务工程
  • maven进行clean package
  • 要求微服务启动的时候,同时配置我们的JVM/GC的调优参数
    • 我们就可以根据具体的业务配置我们启动的JVM参数

例如:

java -Xms1024m -Xmx1024 -XX:UseG1GC -jar   xxx.jar

第 13 章 Linux诊断原因

1 整机:top,查看整机系统性能

image-20200326162329550

使用top命令的话,重点关注的是 %CPU、%MEM 、load average 三个指标

  • load average三个指标:分别代表1、5、15分钟的负载情况

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

uptime:系统性能命令的精简版

2 CPU:vmstat

  • 查看CPU(包含但是不限于)
  • 查看额外
    • 查看所有CPU核信息:mpstat -p ALL 2
    • 每个进程使用CPU的用量分解信息:pidstat -u 1 -p 进程编号

命令格式:vmstat -n 2 3

image-20200326162803165

一般vmstat工具的使用是通过两个数字参数来完成的,第一个参数是残阳的时间间隔数(单位秒),第二个参数是采样的次数

procs

r:运行和等待的CPU时间片的进程数,原则上1核的CPU的运行队列不要超过2,整个系统的运行队列不超过总核数的2倍,否则代表系统压力过大,我们看蘑菇博客测试服务器,能发现都超过了2,说明现在压力过大

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

cpu

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

sy:内核进程消耗的CPU时间百分比

image-20200326164521263

us + sy 参考值为80%,如果us + sy 大于80%,说明可能存在CPU不足,从上面的图片可以看出,us + sy还没有超过百分80,因此说明蘑菇博客的CPU消耗不是很高

id:处于空闲的CPU百分比

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

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

3 内存:free

  • 应用程序可用内存数:free -m

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

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

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

free -h:以人类能看懂的方式查看物理内存

image-20200326170217637

free -m:以MB为单位,查看物理内存

image-20200326165815071

free -g:以GB为单位,查看物理内存

4 硬盘:df

格式:df -h / (-h:human,表示以人类能看到的方式换算)

image-20200326170318733

  • 硬盘IO:iostat

系统慢有两种原因引起的,一个是CPU高,一个是大量IO操作

格式:iostat -xdk 2 3

image-20200326170522559

磁盘块设备分布:

rkB /s:每秒读取数据量kB;

wkB/s:每秒写入数据量kB;

svctm I/O:请求的平均服务时间,单位毫秒

await I/O:请求的平均等待时间,单位毫秒,值越小,性能越好

util:一秒钟有百分几的时间用于I/O操作。接近100%时,表示磁盘带宽跑满,需要优化程序或者增加磁盘;

rkB/s,wkB/s根据系统应用不同会有不同的值,但有规律遵循:长期、超大数据读写,肯定不正常,需要优化程序读取。

svctm的值与await的值很接近,表示几乎没有I/O等待,磁盘性能好,如果await的值远高于svctm的值,则表示I/O队列等待太长,需要优化程序或更换更快磁盘

5 网络IO:ifstat

  • 默认本地没有,下载ifstat

image-20200326171559406

6 生产环境服务器变慢,诊断思路和性能评估

记一次印象深刻的故障?

结合Linux 和 JDK命令一起分析,步骤如下

  • 使用top命令找出CPU占比最高的

  • ps -ef 或者 jps 进一步定位,得知是一个怎么样的后台程序出的问题

  • 定位到具体线程或者代码

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

    image-20200326173656164

  • 将需要的线程ID转换为16进制格式(英文小写格式)

    • printf “%x\n” 有问题的线程ID
  • jstack 进程ID | grep tid(16进制线程ID小写英文) -A60

    精准定位到错误的地方

image-20200326174107444

第 14 章 Github 使用

使用Github优秀框架 + 源码 提升自己

1 常用词含义

  • 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的

  • 组合使用

    • xxx in:name,readme 项目的名称和readme中包含xxx的

2 stars或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 stars:>1000 forks:500..1000

3 awesome加强搜索

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

4 高亮显示某行代码

  • 一行:地址后面紧跟 #L10
    • https://github.com/moxi624/mogu_blog_v2/blob/master/mogu_admin/pom.xml#L13
  • 多行:地址后面紧跟 #Lx - #Ln
    • https://github.com/moxi624/mogu_blog_v2/blob/master/mogu_admin/pom.xml#L13-L30

5 项目内搜索

  • 使用英文字母 t ,开启项目内搜索

image-20200326212650322

6 搜索某个地区内的大佬

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

第 15 章 乐观锁和悲观锁

1 乐观锁

概念

乐观锁:顾名思义,就是十分乐观,它总是认为不会出现问题,无论干什么都不去上锁~,如果出现了问题,再次更新值测试,这里使用了version字段。

也就是每次更新的时候同时维护一个version字段

乐观锁实现方式

  • 取出记录时,获取当前的version
  • 更新时,带上这个version
  • 执行更新时,set version = newVersion where version = oldVersion
  • 如果version不对,就更新失败
乐观锁:1:先查询,获得版本号 version =1

-- A 线程
update user set name = "陌溪", version = version + 1 
where id = 2 and version = 1

-- B 线程抢先完成,这个时候Version = 2,导致A修改失败
update user set name = "陌溪", version = version + 1 
where id = 2 and version = 1

2 MybatisPlus使用乐观锁

首先需要在数据库增加version字典,默认为1

image-20200329172158610

然后在实体类增加对应的字段

// 乐观锁Version注解
@Version
private Integer version;

注册组件,在MybatisPlusConfig中配置

// 注册乐观锁
@Bean
public OptimisticLockerInterceptor optimisticLockerInterceptor() {
	return new OptimisticLockerInterceptor();
}

3 悲观锁

顾名思义,就是十分悲观,它总是认为什么时候都会出现问题,无论什么操作都会上锁,再次操作

第3部分 框架

第16 章 Spring 源码

1 Spring源码方面的知识

  • Spring bean的生命周期
  • Spring 工厂,Spring容器,上下文
  • Spring BeanPostprocessor
  • Spring 和 主流框架的源码
  • Spring BeanFactory 和 FactoryBean的区别

2 谈谈你对Spring的理解

IOC、AOP只是作为Spring Framework里面一部分,同时还有还有events,resources,i18n,validation,data binding,type conversion,SpEL

image-20200402092317669

3 Spring上下文

从代码级别来说,就是指Spring Context

从源码级别,但我们初始化Spring Context的时候,一堆的Spring组件围绕在一起,使其能够正常工作,这个状态就被称为Spring环境

4 Spring初始化

首先需要引入Spring的依赖,因为我们暂时只是初始化过程,只需要用到IOC

<dependency>
    <groupId>org.springframework</groupId>
    <artifactId>spring-context</artifactId>
    <version>5.0.9.RELEASE</version>
</dependency>

为了更加了解Spring初始化的过程,我们需要定义三个类

1、AppConfig.java,可以当成是扫描类,也就是配置我们需要扫描的目录

@Configuration
@ComponentScan("com.pnca.interview.study.spring")
public class AppConfig {
}

2、BeanTest.java,我们需要被扫描到的Bean

@Component
public class BeanTest {
}

3、Test.java,启动测试类

public class Test {
    public static void main(String[] args) {
        // 初始化
        AnnotationConfigApplicationContext annotationConfigApplicationContext = new AnnotationConfigApplicationContext(AppConfig.class);

        System.out.println(annotationConfigApplicationContext.getBean(BeanTest.class));
    }
}

最后我们通过注解的方式,来获取Spring IOC扫描到的Bean,最后打印出来

image-20200402091642787

tip:IDEA点击进去的源码目录,其实是IDEA反编译得到的,和原来的源码会存在一些出入,是IDEA专门优化过的,因此如果你需要修改源码的话,还是需要在官网下载对应的源码包

https://github.com/spring-projects/spring-framework

5 SpringBean的生命周期

Spring中的Bean不可能是直接new关键字创建出来的

  • 把类扫描出来(扫描出来后做了什么?)
  • 把Bean实例化

初始化Spring环境有两种方法,一种是通过注解的方式,一个是通过XML的方式

// 方式1,目前用的比较多
AnnotationConfigApplicationContext annotationConfigApplicationContext = new AnnotationConfigApplicationContext(AppConfig.class)

同时在这个方法的内部,使用了this()

image-20200402100813001

调用AnnotationConfigApplicationContext无参构造方法,同时因为该类又继承了一个父类 GenericApplicationContext,子类在初始化的时候,还会调用父类的无参构造方法,在父类中,我们能够看到它初始化了一个BeanFactory,这就是我们经常提到的Spring工厂

image-20200402100434794

这个工厂最重要的功能就是产生Bean

同时在AnnotationConfigApplication方法的最后,还有一个refresh()方法,这个方法是整个Spring最核心的方法,这个方法的内部,同时调用了十多个方法,其中最重要的是 invokeBeanFactoryPostProcessors()

invokeBeanFactoryPostProcessors() {
	// 扫描类:
	// 处理了各种import:例如@import("xxx.xml"), @MapperScanner, @CompoentScanner ..... 
}

普通类的实例化

普通类的实例化,就是通过javac编译成xxx.class文件,然后某一天通过new关键字进行实例化,JVM就会把这个class类加载到JVM内存中,这里面就涉及到了方法区,堆栈存储等。

image-20200402101551621

Spring Bean实例化过程

  • 首先Spring会将全部的Class类,通过classLoader加载到JVM中

  • 然后在通过扫描,创建很多BeanDefinition,我们通过反射将对应Class的信息填充到BeanDefinition中

    • 这里的BeanDefinition是用来描述Bean的,也就是Bean的一些信息存储

      RootBeanDefinition rootBeanDefinition = new RootBeanDefinition();
      rootBeanDefinition.setBeanClassName("BeanTest");
      rootBeanDefinition.setBeanClass(BeanTest.class);
      rootBeanDefinition.setScope("prototype");
      rootBeanDefinition.setLazyInit();
  • 然后在把填充好的BeanDefinition一个个放入到Map中,Spring扫描了几个类,Map中就有几个类,这个Map被称为 BeanDefinitionMap

image-20200402102958645

  • 最后我们将这个BeanDefinationMap放入了Spring单例池中

image-20200402104318234

完整的加载图,左边红色部分就是Spring的加载过程,然后开放的原则,它还提供了很多扩展接口,让你可以干扰到Sring的加载过程,使得

image-20200402104306006

例如,很多需要对Spring进行扩展的,例如Mybatis,其实都是实现了 BeanFactoryPostProcessor接口

在执行扫描的时候,它会扫描Spring 提供的 BeanFactoryPostProcessor,以及程序员扩展的

public class TestBeanFactoryPostProcessor implements BeanFactoryPostProcessor {
    @Override
    public void postProcessBeanFactory(ConfigurableListableBeanFactory beanFactory) throws BeansException {

    }
}

本博客所有文章除特别声明外,均采用 CC BY-SA 4.0 协议 ,转载请注明出处!