Java并发10:深入浅出并发编程底层原理

164 阅读25分钟

开启掘金成长之旅!这是我参与「掘金日新计划 · 12 月更文挑战」的第10天,点击查看活动详情

学习MOOC视频记录的笔记

1.到底什么叫“底层原理”?本章研究的内容是什么?

  • 重要性:Java面试的必考知识点。只有学会了这一章的内容,才能说你真正懂了并发

  • 从Java代码到CPU指令

  • JVM实现会带来不同的“翻译”,不同的CPU平台机器指令又干差万别,无法保证并发安全的效果一致

  • 重点开始向下转移:转化过程的规范、原则

image-20221113021303107

  1. 最开始,我们编写的 Java 代码,是 *.java 文件

2.在编译(javac命令)后,从刚才*.java文件会变出一个新的Java字节码文件(*.class)

3.JVM 会执行刚才生成的字节码文件( *.class ),并把字节码文件转化为机器指令

4.机器指令可以直接在CPU上执运行,也就是最终的程序执行

2.彩蛋:自顶向下的好处

  • 先讲使用场景,再讲用法,最后讲原理

  • 直观的了解、具体而感性的认识,有助于加深理解,最后分析源码

  • 兴趣:连这个原理有什么作用都不知道的话,我们肯定是没有兴趣的,而没有兴趣就意味着我们学不好

  • 例子:《计算机网络(自顶向下方法)》、C语言学int

3.三兄弟:JVM内存结构 VS Java内存模型 VS Java对象模型

容易混淆:三个截然不同的慨念,但是很多人容易弄混

整体方向:

  • JVM内存结构,和Java虚拟机的运行时区域有关。
  • Java内存模型,和Java的并发编程有关。
  • Java对象模型,和Java对象在虚拟机中的表现形式有关。

3.1 JVM内存结构

image-20221113021924232

  • 堆(heap) 整个运行区域中最大的一块,所占用的内存最多,通过new或者其他指令创建的实例对象,如果实例对象不再被引用时会被垃圾回收,也包括数组;动态分配,被垃圾回收
  • 虚拟机栈(VM Stack) 保存了基本的数据类型以及对象的引用,编译的时候确定了大小并且大小不会被改变
  • 方法区(method) 存储已经加载的各个静态变量或者类信息以及常量信息,还包含永久引用
  • 本地方法栈 和本地方法相关的,即native方法相关的
  • 程序计数器 所占用区域最小,当前线程所执行到的字节码的行号,线程切换时这个数据也会被保存下来

3.2 Java对象模型

image-20221113022724227

Java是面向对象的,所以每个对象在JVM中存储是有一定结构的,而这个结构就是Java对象模型。

主要包含三个部分:栈,堆,方法区。

Java对象自身的存储模型

JVM会给这个类创建一个instanceKlass,保存在方法区,用来在JVM层表示该Java类。

当我们在Java代码中,使用new创建一个对象的时候,JVM会创建一个instanceOopDesc对象,这个对象中包含了对象头以及实例数据

4.JMM是什么

4.1 为什么需要JMM

  • C语言不存在内存模型的概念

  • 依赖处理器,不同处理器结果不一样

  • 无法保证并发安全

  • 需要一个标准,让多线程运行的结果可预期

4.2 JMM是规范

  • Java Memory Model Java内存模型

  • 是一组规范,需要各个JVM的实现来遵守JMM规范,以便于开发者可以利用这些规范,更方便地开发多线程程序

  • 如果没有这样的一个JMM内存模型来规范,那么很可能经过了不同JVM的不同规则的重排序之后,导致不同的虚拟机上运行的结果不一样,那是很大的问题。

4.3 JMM是工具类和关键字的原理

  • volatile、synchronized、Lock等的原理都是JMM
  • 如果没有JMM,那就需要我们自己指定什么时候用内存栅栏等,那是相当麻烦的,幸好有了JMM,让我们只需要用同步工具和关键字就可以开发并发程序。

5.重排序

  • 重排序的代码案例、什么是重排序

  • 重排序的好处:提高处理速度

  • 重排序的3种情况:编译器优化、CPU指令重排、内存的“重排序”

5.1 什么是重排序

public class OutOfOrderExecution {
    private static int x = 0, y = 0;
    private static int a = 0, b = 0;
 
    public static void main(String[] args) throws InterruptedException {
        Thread one = new Thread(new Runnable() {
            @Override
            public void run() {
                a = 1;
                x = b;
            }
        });
 
        Thread two = new Thread(new Runnable() {
            @Override
            public void run() {
                b = 1;
                y = a;
            }
        });
        one.start();
        two.start();
        one.join();
        two.join();
        System.out.println("x = " + x + ", y = " + y);
    }
}

这4行代码的执行顺序决定了最终x和y的结果,一共有3种情况:

  1. a=1;x=b(0);b=1;y=a(1),最终结果是x=0,y=1
  2. b=1;y=a(0);a=1;x=b(1),最终结果是x=1,y=0
  3. b=1;a=1;x=b(1);y=a(1),最终结果是x=1,y=1

虽然代码执行顺序可能有多种情况,但是在线程1内部,也就是:

a = 1;
x = b;

这两行代码的执行顺序,是不会改变的,也就是a=1会在x=b前执行;同理,线程2的b=1会在y=a前执行。

会出现x=0,y=0?那是因为重排序发生了,4行代码的执行顺序的其中一种可能:

y = a;
a = 1;
x = b;
b = 1;

什么是重排序:在线程1内部的两行代码的实际执行顺序和代码在Java文件中的顺序不一致,代码指令并不是严格按照代码语句顺序执行的,它们的顺序被改变了,这就是重排序,这里被颠倒的是y=a和b=1这两行语句

5.2 重排序的好处:提高处理速度

对比重排序前后的指令优化

image-20221114013743528

对a而言节省了一次读取和一次写入,重排序明显提高了处理速度。

5.3 重排序的三种情况

  • 编译器优化:包括JVM,JIT编译器等
  • CPU指令重排:就算编译器不发生重排,CPU也可能对指令进行重排
  • 内存的“重排序”:线程A的修改线程B却看不到,引出可见性问题

6.可见性

6.1 案例:演示什么是可见性问题

public class FieldVisibility {
    int a = 1;
    int b = 2;
 
    private void change() {
        a = 3;
        b = a;
    }
 
    private void print() {
        System.out.println("b = " + b + ", a = " + a);
    }
 
    public static void main(String[] args) {
        while (true) {
            FieldVisibility test = new FieldVisibility();
            new Thread(new Runnable() {
                @Override
                public void run() {
                    try {
                        Thread.sleep(1);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                    test.change();
                }
            }).start();
 
            new Thread(new Runnable() {
                @Override
                public void run() {
                    try {
                        Thread.sleep(1);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                    test.print();
                }
            }).start();
        }
    }
}
 

分析这四种情况:

a = 3, b = 3 (线程1先执行完)
a = 1, b = 2 (线程2先执行完)
a = 3, b = 2 (交替运行)
a = 1, b = 3

image-20221114021452182

两个线程在不同的核心上面执行,每个核心都有一块自己的内存(本地内存),线程1的本地内存和线程2的本地内存之间是不能直接通信的,而是需要通过共享内存shared cache来通信,因此有可能出现数据在本地内存中被修改了x = 1,但是从共享内存中读到的还是之前的老值x = 0,没有看到修改,即出现了可见性问题。

案例二:

public class FieldVisibility {
    int x = 0;
 
    public void writerThread() {
        x = 1;
    }
 
    public void readerThread() {
        int r2 = x;
    }
}

image-20221114021934044

初始时均为0

image-20221114022017202

读到过期的值。

使用 volatile 来解决,强制每次读取,读取到的都是线程已经修改过的最新的值,不会出现可见性问题。

image-20221114022322131

将正确的值刷回共享内存

image-20221114022442978

6.2 为什么会有可见性问题

image-20221114022509562

从下往上看,最下面是主内存RAM,容量最大,速度最慢,距离CPU最远。从内存到CPU有多层缓存,L3会把内存中使用频率高的缓存起来,往上走缓存的容量缩小,但是速度加快。如果直接让CPU从RAM中取数据会极大的降低CPU的效率,但是拿的不是主存的内容,因此会出现可见性问题。

CPU有多级缓存,导致读的数据过期

  • 高速缓存的容量比主内存小,但是速度仅次于寄存器,所以在CPU和主内存之间就多了Cache层
  • 线程间的对于共享变量的可见性问题不是直接由多核引起的,而是由多缓存引起的
  • 如果所有个核心都只用一个缓存,那么也就不存在内存可见性问题
  • 每个核心都会将自己需要的数据读到独占缓存中,数据修改后也是写入到缓存中,然后等待刷入到主存中。所以会导致有些核心读取的值是一个过期的值

6.3 JMM的抽象:主内存和本地内存

6.3.1 什么是主内存和本地内存

  • Java作为高级语言,屏蔽了这些底层细节,用JMM定义了一套读写内存数据的规范,虽然我们不再需要关心一级缓存和二级缓存的问题,但是,JMM抽象了主内存和本地内存的概念。

  • 这里说的本地内存并不是真的是一块给每个线程分配的内存,而是JMM的一个抽象,是对于寄存器、一级缓存、二级缓存等的抽象

image-20221114233331781

每个线程首先和自己的工作内存(Working Memory)打交道,不同的核心之间的工作内存之间是不互通的,他们之间通过一个Buffer和主内存沟通,并且交互也只能通过主内存,而不能直接互通。

即兼顾的效率也降低的开发的难度。

image-20221115000256007

每个线程有各自的工作线程,和主内存同步。

6.3.2 主内存和本地内存的关系

JMM有以下规定:

  1. 所有的变量都存储在内存中,同时每个线程也有自己独立工作内存,工作内存中的变量内容是主内存中的拷贝
  2. 线程不能直接读写主内存中的变量,而是只能操作自己工作内存中的变量然后再同步到主内存中
  3. 主内存多个线程共享的,但线程间不共享工作内存,如果线程间需要通信,必须借助主内存中转来完成

所有的共享变量存在于主内存中,每个线程有自己的本地内存,而且线程读写共享数据也是通过本地内存交换的,所以才导致了可见性问题

6.4 Happens-Before原则

6.4.1 什么是happens-before

  • happens-before 规则是用来解决可见性问题的:在时间上,动作A发生在动作B之前,B保证能看见A,这就是happens-before

  • 两个操作可以用 happens-before 来确定它们的执行顺序:如果一个操作happens-before于另一个操作,那么我们说第一个操作对于第二个操作是可见的

6.4.2 什么不是happens-before

  • 两个线程没有相互配合的机制,所以代码X和Y的执行结果并不能保证总被对方看到的,这就具备happens-before

6.4.3 Happens-Before规则有哪些?

  1. 单线程规则

image-20221115001926515

在一个线程之内后面的语句一定能看到前面的语句做了什么,Happens Before不影响重排序

A happens-before B 并不代表A一定要在B之前执行,假如先B后A的执行操作与先A后B的执行操作结果相同,JMM就认为这种重排序的合法的

  1. 锁操作(synchronizedLock)

image-20221115002238779

加锁之后一定能看到解锁之前的所有操作

image-20221115002443685

第一个线程A抢到锁之后会执行被同步代码块保护的代码,执行完毕之后轮到B了,这个时候B一定能看到所有的A拿到锁之后的操作。

  1. volatile变量

image-20221115002628715

左侧是写入右侧是读取,只要已经写入了并且这个变量是被volatile修饰的,那么读取的时候一定能读取到最新的结果。

  1. 线程启动

image-20221115002841196

子线程所执行的所有语句都能看到主线程之前的所有语句的发生结果。

  1. 线程join

image-20221115003037071

join后面的语句一定能看到所有刚才等待线程里面执行的所有语句

  1. 传递性

如果hb(A,B)而且hb(B,C),那么可以推出hb(A,C)

  1. 中断

一个线程被其他线程interrupt时,那么检测中断(isInterrupted)或者抛出InterruptedException一定能看到。

  1. 构造方法

构造方法:对象构造方法的最后一行指令happens-before于finalize()方法的第一行指令

  1. 工具类的Happens-Before原则

(1) 线程安全的容器get一定能看到在此之前的put等存入动作

(2) CountDownLatch

(3) Semaphore

(4) Future

(5) 线程池

(6) CyclicBarrier

6.4.4 优质代码案例:happens-before演示

happens-before有一个原则是:如果A是对volatile变量的写操作,B是对同一个变量的读操作,那么hb(A,B)。

分析这四种情况:

a = 3, b = 2
a = 1, b = 2
a = 3, b = 3
a = 1, b = 3 ???

第四种情况(概率低):没给b加volatile,那么有可能出现a=1,b=3。因为a虽然被修改了,但是其他线程不可见,而b恰好其他线程可见这就造成了b=3,a=1。

只用给b加上volatile就可以了

public class FieldVisibility {
    int a = 1;
    // 读取b的时候可以看到b写入之前的所有操作
    volatile int b = 2;
 
    private void change() {
        a = 3;
        b = a;
    }
}
  • 近朱者赤:给b加了volatile,不仅b被影响,也可以实现轻量级同步
  • b之前的写入(对应代码b=a)对读取b后的代码(print b)都可见所以在writerThread里对a的赋值,一定会对readerThread里的读取可见,所以这里的a即使不加volatile,只要b读到是3,就可以由happens-before原则保证了读取到的都是3而不可能读取到1

6.5 volatile关键字

6.5.1 volatile是什么

  • volatile是一种同步机制,比 synchronized 或者 Lock 相关类更轻量,因为使用 volatile不会发生上下文切换等开销很大的行为。
  • 如果一个变量名修饰成 volatile,那么JVM就知道了这个变量可能会被并发修改
  • 但是开销小,相应的能力也小,虽然说 volatile 是用来同步的保证线程安全的,但是 volatile 做不到 synchronized 那样的原子保护volatile 仅在很有限的场景下才能发挥作用。

6.5.2 volatile的适用场合

  • 不适用:a++
/**
* 不适用于volatile的场景
*/
public class NoVolatile implements Runnable {
    volatile int a;
    AtomicInteger realA = new AtomicInteger();
 
    public static void main(String[] args) throws InterruptedException {
        Runnable r = new NoVolatile();
        Thread thread1 = new Thread(r);
        Thread thread2 = new Thread(r);
        thread1.start();
        thread2.start();
        thread1.join();
        thread2.join();
        System.out.println(((NoVolatile) r).a);
        System.out.println(((NoVolatile) r).realA.get());
    }
 
    @Override
    public void run() {
        for (int i = 0; i < 10000; i++) {
            a++;
            realA.incrementAndGet();
        }
    }
}

运行结果:

16752
20000

适用场合1:boolean flag,如果一个共享变量自始至终只被各个线程赋值,而没有其他的操作,那么就可以用 volatile 来代替 synchronized或者代替原子变量,因为赋值自身是有原子性的,而 volatile 又保证了可见性,所以就足以保证线程安全。

/**
* volatile适用的情况1
*/
public class UseVolatile1 implements Runnable {
    volatile boolean done = false;
    AtomicInteger realA = new AtomicInteger();
 
    public static void main(String[] args) throws InterruptedException {
        Runnable r = new UseVolatile1();
        Thread thread1 = new Thread(r);
        Thread thread2 = new Thread(r);
        thread1.start();
        thread2.start();
        thread1.join();
        thread2.join();
        System.out.println(((UseVolatile1) r).done);
        System.out.println(((UseVolatile1) r).realA.get());
    }
 
    @Override
    public void run() {
        for (int i = 0; i < 10000; i++) {
            setDone();
            realA.incrementAndGet();
        }
    }
 
    private void setDone() {
        // 不取决于之前的状态
        done = true;
    }
}

运行结果:

true
20000

取决于之前状态的情况:

/**
* volatile不适用的情况2
*/
public class NoVolatile2 implements Runnable {
    volatile boolean done = false;
    AtomicInteger realA = new AtomicInteger();
 
    public static void main(String[] args) throws InterruptedException {
        Runnable r = new NoVolatile2();
        Thread thread1 = new Thread(r);
        Thread thread2 = new Thread(r);
        thread1.start();
        thread2.start();
        thread1.join();
        thread2.join();
        System.out.println(((NoVolatile2) r).done);
        System.out.println(((NoVolatile2) r).realA.get());
    }
 
    @Override
    public void run() {
        for (int i = 0; i < 10000; i++) {
            flipDone();
            realA.incrementAndGet();
        }
    }
 
    private void flipDone() {
        done = !done;
    }
}

运行结果:

true  # 翻转执行的不是20000次
20000

适用场合2:作为刷新之前变量的触发器

public class FieldVisibility {
    int a = 1;
    int abc = 1;
    int abcd = 1;
    volatile int b = 2;
 
    private void change() {
        // 写入之前的操作
        abc = 7;
        abcd = 70;
        a = 3;
        // 写入操作
        b = 0;
    }
 
    private void print() {
        if (b == 0) {
            System.out.println("a = " + a);
            System.out.println("abc = " + abc);
            System.out.println("abcd = " + abcd);
        }
        System.out.println("b = " + b + ", a = " + a);
    }
 
    public static void main(String[] args) {
        while (true) {
            FieldVisibility test = new FieldVisibility();
            new Thread(new Runnable() {
                @Override
                public void run() {
                    try {
                        Thread.sleep(1);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                    test.change();
                }
            }).start();
 
            new Thread(new Runnable() {
                @Override
                public void run() {
                    try {
                        Thread.sleep(1);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                    test.print();
                }
            }).start();
        }
    }
}

生产中经典的例子:

Map configOptions;
char[] configText;
// 触发器
volatile boolean initialized = false;
 
// Thread A
configOptions = new HashMap();
configText = readConfigFile(fileName);
processConfigOptions(configText, configOptions);
initialized = true;
 
// Thread B
// 当线程B看到变量initialized变成true了,由于可见性的保证,那么 initialized = true; 上面的语句都被执行完了
while (!initialized)
    sleep();
// use configOptions

6.5.3 volatile的作用:可见性、禁止重排序

  1. 可见性:读一个 volatile 变量之前,需要先使相应的本地缓存失效,这样就必须到主内存读取最新值,写一个 volatile 属性会立即刷入到主内存
  2. 禁止指令重排序优化:解决单例双重锁乱序问题

6.5.4 volatile和synchronized的关系?

volatile 在这方面可以看做是轻量版的synchronized:如果一个共享变量自始至终只被各个线程赋值,而没有其他的操作,那么就可以用volatile来代替synchronized或者代替原子变量,因为赋值自身是有原子性的,而volatile又保证了可见性,所以就足以保证线程安全。

6.5.5 学以致用:用volatile修正重排序问题

OutOfOrderExecution 类加了 volatile 后,用于不会出现(O,O)的情况了。

public class OutOfOrderExecution {
    private volatile static int x = 0, y = 0;
    private volatile static int a = 0, b = 0;
 
    public static void main(String[] args) throws InterruptedException {
        // case1();
        // case2();
 
        for (int i = 0; ; i++) {
            x = 0; y = 0;
            a = 0; b = 0;
            CountDownLatch latch = new CountDownLatch(1);
            Thread one = new Thread(new Runnable() {
                @Override
                public void run() {
                    try {
                        latch.await();
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                    a = 1;
                    x = b;
                }
            });
 
            Thread two = new Thread(new Runnable() {
                @Override
                public void run() {
                    try {
                        latch.await();
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                    b = 1;
                    y = a;
                }
            });
            one.start();
            two.start();
            latch.countDown();
            one.join();
            two.join();
            String result = "第" + i + "次 (" + x + "," + y + ")";
            System.out.println(result);
            if (x == 1 && y == 1) break;
 
            if (x == 0 && y == 0) break;
        }
    }
 
    private static void case1() throws InterruptedException {
        Thread one = new Thread(new Runnable() {
            @Override
            public void run() {
                a = 1;
                x = b;
            }
        });
 
        Thread two = new Thread(new Runnable() {
            @Override
            public void run() {
                b = 1;
                y = a;
            }
        });
        one.start();
        two.start();
        one.join();
        two.join();
        System.out.println("x = " + x + ", y = " + y);
    }
 
    private static void case2() throws InterruptedException {
        Thread one = new Thread(new Runnable() {
            @Override
            public void run() {
                a = 1;
                x = b;
            }
        });
 
        Thread two = new Thread(new Runnable() {
            @Override
            public void run() {
                b = 1;
                y = a;
            }
        });
        // 交换顺序
        two.start();
        one.start();
        one.join();
        two.join();
        System.out.println("x = " + x + ", y = " + y);
    }
}

6.5.6 volatile小结

  1. volatile 修饰符适用于以下场景:某个属性被多个线程共享,其中有一个线程修改了此属性,其他线程可以立即得到修改后的值,比如boolean flag;或者作为触发器,实现轻量级同步。
  2. volatile 属性的读写操作都是无锁的,它不能替代 synchronized,因为它没有提供原子性互斥性。因为无锁,不需要花费时间在获取锁和释放锁上,所以说它是低成本的。
  3. volatile 只能作用于属性,我们用 volatile 修饰属性,这样compilers就不会对这个属性做指令重排序
  4. volatile 提供了可见性,任何一个线程对其的修改将立马对其他线程可见。volatile属性不会被线程缓存,始终从主存中读取。
  5. volatile 提供了 happens-before 保证,对 volatile 变量 v 的写入 happens-before 所有其他线程后续对 v 的读操作。
  6. volatile 可以使得 longdouble 的赋值是原子的,后面马上会讲 longdouble 的原子性。

6.6 能保证可见性的措施

  • 除了 volatile 可以让变量保证可见性外,synchronizedLock、并发集合、Thread.join()Thread.start() 等都可以保证的可见性
  • 具体看 happens-before 原则的规定

6.7 升华:对synchronized可见性的正确理解

  • synchronized 不仅保证了原子性,还保证了可见性 【获取最新值】
  • synchronized,不仅让被保护的代码安全,还近朱者赤 【前面的代码一定执行了】
/**
* 演示Synchronized可见性
*/
public class FieldVisibilitySyn {
    int a = 1;
    int b = 2;
    int c = 2;
    int d = 2;
 
    private void change() {
        a = 4;
        b = 4;
        c = 5;
        // 保证abcd都被修改了且被读取到
        synchronized (this) {
            d = 6;
        }
        // 解锁
    }
 
    private void print() {
        // 加锁
        synchronized (this) {
            int aa = a;
        }
        int bb = b;
        int cc = c;
        int dd = d;
 
    }
 
    public static void main(String[] args) {
        while (true) {
            FieldVisibilitySyn test = new FieldVisibilitySyn();
            new Thread(new Runnable() {
                @Override
                public void run() {
                    try {
                        Thread.sleep(1);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                    test.change();
                }
            }).start();
 
            new Thread(new Runnable() {
                @Override
                public void run() {
                    try {
                        Thread.sleep(1);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                    test.print();
                }
            }).start();
        }
    }
}

7.原子性

7.1 什么是原子性

  • 一系列的操作,要么全部执行成功,要么全部不执行,不会出现执行一半的情况,是不可分割的。

  • ATM里取钱

  • i++不是原子性的

  • synchronized:实现原子性

image-20221116004642987

7.2 Java中的原子操作有哪些

  • longdouble 之外的基本类型(int,byte,boolean,short,char,float)的赋值操作
  • 所有引用 reference 的赋值操作,不管是32位的机器还是64位的机器
  • java.concurrent.Atomic.* 包中所有类的原子操作

7.3 long和double的原子性

image-20221116225542796

  • 问题描述:官方文档、对于64位的值的写入,可以分为两个32位的操作进行写入、读取错误、使用 volatile 解决
  • 结论:在32位上的JVM上,long和doublet的操作不是原子的,但是在64位的VM上是原子的
  • 实际开发中:商用Java虚拟机中不会出现

7.4 原子操作+原子操作!=原子操作

  • 简单地把原子操作组合在一起,并不能保证整体依然具有原子性
  • 比如我去ATM机两次取钱是两次独立的原子操作,但是期间有可能银行卡被借给女朋友,也就是被其他线程打断并被修改。
  • 全同步的HashMap也不完全安全

比如一个同步HashMap,所有方法都加了syn同步保护,多线程同时操作的时候不会有太大的问题,但是并不是完全安全的。取出 key = 2的value,看看是不是5,如果是5就将其加1,因为这是一系列操作,虽然每个操作单独拿出来都没有问题,但是不同操作组合在一起就可能有问题。

8.面试常见问题

8.1 单例模式

1.JMM应用实例单例模式8种写法、单例和并发的关系(真实面试超高频考点)

单例模式的作用

(1) 为什么需要单例:节省内存和计算、保证结果正确、方便管理

private Resource rs = new Resource();
 
public Resource getExpensiveResource() {
    return rs;
}
 
public Resource() {
    field1 = //  some CPU heavy logic
       field2 = //  some value from DB
    field3 = //  etc.
}

(2) 单例模式适用场景

  1. 无状态的工具类:比如日志工具类,不管是在哪里使用,我们需要的只是它帮我们记录日志信息,除此之外,并不需要在它的实例对象上存储任何状态,这时候我们就只需要一个实例对象即可。
  2. 全局信息类:比如我们在一个类上记录网站的访问次数,我们不希望有的访问被记录在对象A上,有的却记录在对象B上,这时候我们就让这个类成为单例。

单例模式的8种写法

8.1.1 饿汉式(静态常量) 可用

/**
* 饿汉式(静态常量) (可用)
*
* 优点:
 *      1. 写法简单
 *      2. 类加载的时候将对象实例化完毕,避免了线程同步的问题 (类的加载由JVM保证线程安全)
*/
public class Singleton1 {
    private final static Singleton1 INSTANCE = new Singleton1();
 
    private Singleton1() {
 
    }
 
    public static Singleton1 getInstance() {
        return INSTANCE;
    }
}

8.1.2 饿汉式(静态代码块) 可用

/**
* 饿汉式 (静态代码块) (可用)
*
* 优点:
 *      1. 写法简单
 *      2. 类加载的时候将对象实例化完毕,避免了线程同步的问题 (类的加载由JVM保证线程安全)
*/
public class Singleton2 {
    private final static Singleton2 INSTANCE;
 
    static {
        INSTANCE = new Singleton2();
    }
 
    private Singleton2() {
 
    }
 
    public static Singleton2 getInstance() {
        return INSTANCE;
    }
}

8.1.3 懒汉式(线程不安全)不可用]

/**
* 懒汉式 (线程不安全)
*/
public class Singleton3 {
    private static Singleton3 instance;
 
    private Singleton3() {
 
    }
 
    public static Singleton3 getInstance() {
        if (instance == null) {
            instance = new Singleton3();
        }
        return instance;
    }
}

8.1.4 懒汉式(线程安全,同步方法)【不推荐用】

/**
* 懒汉式 (线程不安全) (不推荐)
*/
public class Singleton4 {
    private static Singleton4 instance;
 
    private Singleton4() {
 
    }
 
    // 多线程无法同时进入此方法,效率低
    public synchronized static Singleton4 getInstance() {
        if (instance == null) {
            instance = new Singleton4();
        }
        return instance;
    }
}

8.1.5 懒汉式(线程不安全,同步代码块)【不可用】

/**
* 懒汉式 (线程不安全) (不可用)
*/
public class Singleton5 {
    private static Singleton5 instance;
 
    private Singleton5() {
 
    }
 
    public static Singleton5 getInstance() {
        if (instance == null) {
            // 两个线程同时进入if判断,第一个线程拿到锁之后创建实例,第二个线程也同样会拿到锁后创建实例
            synchronized (Singleton5.class) {
                instance = new Singleton5();
            }
        }
        return instance;
    }
}

8.1.6 双重检查【推荐用】:star:

/**
* 双重检查 推荐面试使用
*/
public class Singleton6 {
    // 必须要有可见性
    private volatile static Singleton6 instance;
 
    private Singleton6() {
 
    }
 
    public static Singleton6 getInstance() {
        if (instance == null) {
            synchronized (Singleton6.class) {
                // 快捷键 ifn
                if (instance == null) {
                    instance = new Singleton6();
                }
            }
        }
        return instance;
    }
}
  • 优点:线程安全;延迟加载;效率较高。
  • 为什么要double-check?
    • 双重锁才能保证线程安全,
    • 单check行不行
    • 性能问题
  • 为什么要用 volatile
    • 新建对象实际上有3个步骤
    • 重排序会带来NPE
    • 防止重排序

image-20221117002201034

新建单例实例的一行代码 rs = new Resource(); 实际上由三个步骤组成,1.创建一个空的resource();2.执行构造方法赋值;3.将创建的实例赋值到左边的引用上,这样其他的线程都可以使用这个引用了。但是对于CPU和编译器而言,其实是有重排序功能的,实际的执行顺序有可能是 1 3 2(最后调用构造方法进行初始化)

会发生什么问题呢?

第一个线程进来执行完 construct empty resource()assign to rs,左边的 rs 已经不是null了,但是这个对象的属性是没有内涵的,即属性都是没有被赋值的,此时如果第二个线程进来看到 rs != null,即便rs内部还没有完全准备完毕,跳出判断返回rs,如果第二个线程继续使用这个实例访问里面的值会发现结果为空,出现空指针异常,因此需要使用 volatile 来防止重排序。

8.1.7 静态内部类【推荐用】

/**
* 静态内部类的方式 可用
 * 懒汉  需要的时候才被加载
*/
public class Singleton7 {
 
    private Singleton7() {
 
    }
 
    private static class SingletonInstance {
        private static final Singleton7 INSTANCE = new Singleton7();
    }
 
    public static Singleton7 getInstance(){
        return SingletonInstance.INSTANCE;
    }
}

8.1.8 枚举【推荐用】:star:

/**
* 枚举单例
*/
public enum Singleton8 {
    INSTANCE;
 
    public void whatever() {
 
    }
}

调用方法:

Singleton8.INSTANCE.whatever();

8.2 不同写法对比

  • 饿汉:简单,但是没有 lazy loading
  • 懒汉:有线程安全问题
  • 静态内部类:可用
  • 双重检查:面试用
  • 枚举:最好

8.3 用哪种单例的实现方案最好?

  • Joshua Bloch大神在《Effective Java》中明确表达过的观点:"使用枚举实现单例的方法虽然还没有广泛采用,但是单元素的枚举类型已经成为实现Singleton的最佳方法。
  • 写法简单
  • 线程安全有保障
  • 避免反序列化破坏单例

8.4 各种写法的使用场合

  • 最好的方法是利用枚举,因为还可以防止反序列化重新创建新的对象;
  • 非线程同步的方法不能使用;
  • 如果程序一开始要加载的资源太多,那么就应该使用懒加载
  • 饿汉式如果是对象的创建需要配置文件就不适用。
  • 懒加载虽然好,但是静态内部类这种方式会引入编程复杂性

8.5 单例模式面试常见问题

  • 饿汉式的缺点?

    上来直接加载,有时候我们不需要,造成了资源的浪费,启动时间延长了

  • 懒汉式的缺点?

    写法较为复杂,线程安全问题

  • 为什么要用double-check?不用就不安全吗?

    单重锁不安全

  • 为什么双重检查模式要用volatile?

    禁止指令重排(创建空对象,调用构造方法,实例地址分配给引用) 132就会有空指针异常

    同时也保证了可见性问题,第一个线程创建的对象其他线程可以看到

  • 应该如何选择,用哪种单例的实现方案最好?

    枚举,写法简单,线程安全,方式反序列化破坏单例

8.6 讲一讲什么是Java内存模型

首先讲起因,为什么要有这个,C语言没有这个会有什么问题:即在多核情况下表现不一致。JVM内存结构、Java内存模型,Java对象模型;

Java内存模型首先是一组规范,规范了我们JVM/CPU/Java代码之间一系列转换的关系,来帮助我们程序员更容易开发,重排序,可见性和原子性

可见性:对内存的抽象,主内存和本地内存,happens-before原则,volatile关键字,适用场合以及和synchronized的关系

8.7 volatile和synchronized的异同?

volatile可以看成是轻量级的synchronized,开销比较轻量级,适用场合比较有限,比如被多个不同线程赋值的变量,而不存在累加等操作

8.8 什么是原子操作?Java中有哪些原子操作?生成对象的过程是不是原子操作?

  1. 新建一个空的Person对象

  2. 把这个对象的地址指向p

  3. 执行Person的构造函数

8.9 什么是内存可见性?

image-20221119170304575

多层缓存会造成你看不见我我看不见你的问题,于是有了JMM这样的抽象

8.10 64位的double和long写入的时候是原子的吗

Java没有规定它们一定是原子的,因为它们本身是64位的,写入的时候有可能出现前32位和后32位错位的情况,但是这只是理论上,实际上商用的JVM中不存在这个问题。所以实际开发中不需要对此进行处理,通常不需要考虑。