[Java并发与多线程](十一)Java内存模型——底层原理

Java内存模型——底层原理

1、底层原理

我们在Java代码中,使用的控制并发的手段例如synchronized关键字,最终也是要转化为CPU指令来生效的,
我们来回顾一下,从Java代码到最终执行的CPU指令的流程

  1. 最开始,我们编写的Java代码,是*.java文件
  2. 在编译(javac命令)后,从刚才的*.java文件会变成一个新的Java字节码文件*.class);
  3. JVM会执行刚才生成的字节码文件(*.class),并把字节码文件转化为机器指令 ;
  4. 机器指令可以直接在CPU上运行,也就是最终的程序执行 。

而不同的JVM实现会带来不同的翻译,不同的CPU平台的机器指令又千差万别;所以我们在java代码层写的各种Lock,其实最后依赖的是JVM的具体实现(不同版本会有不同实现)和CPU的指令,才能帮我们达到线程安全的效果。由于最终效果依赖处理器,不同处理器结果不一样,这样无法保证并发安全,所以需要一个标准,让多线程运行的结果可预期,这个标准就是Java内存模型(JMM)

2、三兄弟:JVM内存结构 VS Java内存模型 VS Java对象模型

2.1、JVM内存结构:和Java虚拟机的运行时区域有关

JVM内存结构
线程共享的:堆、方法区;
线程私有的:虚拟机栈(Java栈)、本地方法栈、程序计数器;

2.1.1 堆

Java堆是Java 虚拟机所管理的内存中最大的一块,Java 堆是所有线程共享的一块内存区域,在虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例,几乎所有的对象实例以及数组都在这里分配内存

Java 堆是垃圾收集器管理的主要区域,因此也被称作GC堆(Garbage Collected Heap)。从垃圾回收的角度,由于现在收集器基本都采用分代垃圾收集算法,所以Java堆还可以细分为:新生代老年代:再细致一点有:Eden空间、From Survivor、To Survivor空间等。进一步划分的目的是更好地回收内存,或者更快地分配内存

根据Java虚拟机规范的规定,Java堆可以处于物理上不连续的内存空间中,只要逻辑上是连续的即可,就像我们的磁盘空间一样。在实现时,既可以实现成固定大小的,也可以是可扩展的,不过当前主流的虚拟机都是按照可扩展来实现的(通过-Xmx和-Xms控制)。
如果在堆中没有内存完成实例分配,并且堆也无法再扩展时,将会抛出OutOfMemoryError异常

在 JDK 1.8中移除整个永久代,取而代之的是一个叫元空间(Metaspace)的区域(永久代使用的是JVM的堆内存空间,而元空间使用的是物理内存,直接受到本机的物理内存限制)。
Java8内存模型—永久代(PermGen)和元空间(Metaspace)

2.1.2 方法区

方法区与 Java 堆一样,是各个线程共享的内存区域,它用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。虽然Java虚拟机规范把方法区描述为堆的一个逻辑部分,但是它却有一个别名叫做Non-Heap(非堆),目的应该是与Java堆区分开来。

对于习惯在HotSpot虚拟机上开发和部署程序的开发者来说,很多人愿意把方法区称为“永久代”(Permanent Generation),本质上两者并不等价,仅仅是因为HotSpot虚拟机的设计团队选择把GC分代收集扩展至方法区,或者说使用永久代来实现方法区而已。

Java虚拟机规范对这个区域的限制非常宽松,除了和Java堆一样不需要连续的内存和可以选择固定大小或者可扩展外,还可以选择不实现垃圾收集。相对而言,垃圾收集行为在这个区域是比较少出现的,但并非数据进入了方法区就如永久代的名字一样“永久”存在了。这个区域的内存回收目标主要是针对常量池的回收和对类型的卸载,一般来说这个区域的回收“成绩”比较难以令人满意,尤其是类型的卸载,条件相当苛刻,但是这部分区域的回收确实是有必要的。

根据Java虚拟机规范的规定,当方法区无法满足内存分配需求时,将抛出OutOfMemoryError异常

2.1.3 程序计数器

程序计数器(Program Counter Register)是一块较小的内存空间,它的作用可以看做是当前线程所执行的字节码的行号指示器。在虚拟机的概念模型里(仅是概念模型,各种虚拟机可能会通过一些更高效的方式去实现),字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。

(为什么程序计数器是线程私有的?)
由于Java虚拟机的多线程是通过线程轮流切换并分配处理器执行时间的方式来实现的,在任何一个确定的时刻,一个处理器(对于多核处理器来说是一个内核)只会执行一条线程中的指令。因此,为了线程切换后能恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器,各条线程之间的计数器互不影响,独立存储,我们称这类内存区域为线程私有的内存。

如果线程正在执行的是一个Java方法,这个计数器记录的是正在执行的虚拟机字节码指令的地址;如果正在执行的是Natvie方法,这个计数器值则为空(Undefined)。

从上面的介绍中我们知道程序计数器主要有两个作用:

  1. 字节码解释器通过改变程序计数器来依次读取指令,从而实现代码的流程控制,如:顺序执行、选择、循环、异常处理。
  2. 在多线程的情况下,程序计数器用于记录当前线程执行的位置,从而当线程被切换回来的时候能够知道该线程上次运行到哪儿了。

注意:程序计数器是唯一一个不会出现OutOfMemoryError的内存区域,它的生命周期随着线程的创建而创建,随着线程的结束而死亡。

2.1.4 Java 虚拟机栈

与程序计数器一样,Java虚拟机栈也是线程私有的,它的生命周期和线程相同,描述的是 Java 方法执行的内存模型。

Java 内存可以粗糙的区分为堆内存(Heap)和栈内存(Stack),其中栈就是现在说的虚拟机栈,或者说是虚拟机栈中局部变量表部分。实际上,Java虚拟机栈是由一个个栈帧组成,而每个栈帧中都拥有:局部变量表操作数栈动态链接方法出口信息
局部变量表存放了编译器可知的各种基本数据类型(boolean、byte、char、short、int、long、float、double)、对象引用(reference类型,它不等同于对象本身,根据不同的虚拟机实现,它可能是一个指向对象起始地址的引用指针,也可能指向一个代表对象的句柄或者其他与此对象相关的位置)和returnAddress类型(指向了一条字节码指令的地址)。
Java 虚拟机栈会出现两种异常:StackOverFlowErrorOutOfMemoryError

  • StackOverFlowError: 若Java虚拟机栈的内存大小不允许动态扩展,那么当线程请求栈的深度超过当前Java虚拟机栈的最大深度的时候,就抛出StackOverFlowError异常。
  • OutOfMemoryError: 若 Java 虚拟机栈的内存大小不允许动态扩展,且当线程请求栈时内存用完了,无法再动态扩展了,此时抛出OutOfMemoryError异常。

Java 虚拟机栈也是线程私有的,每个线程都有各自的Java虚拟机栈,而且随着线程的创建而创建,随着线程的死亡而死亡。

2.1.5 本地方法栈

和虚拟机栈所发挥的作用非常相似,区别是: 虚拟机栈为虚拟机执行Java 方法 (也就是字节码)服务,而本地方法栈则为虚拟机使用到的Native方法服务。 在 HotSpot 虚拟机中和 Java 虚拟机栈合二为一。

本地方法被执行的时候,在本地方法栈也会创建一个栈帧,用于存放该本地方法的局部变量表、操作数栈、动态链接、出口信息

方法执行完毕后相应的栈帧也会出栈并释放内存空间,也会出现StackOverFlowErrorOutOfMemoryError两种异常。

2.1.6 运行时常量池

运行时常量池是方法区的一部分。Class 文件中除了有类的版本、字段、方法、接口等描述信息外,还有常量池信息(用于存放编译器生成的各种字面量和符号引用

既然运行时常量池是方法区的一部分,自然受到方法区内存的限制,当常量池无法再申请到内存时会抛出OutOfMemoryError异常。
JDK1.7及之后版本的 JVM 已经将运行时常量池从方法区中移了出来,在 Java 堆(Heap)中开辟了一块区域存放运行时常量池。
方法区和常量池
字符串常量池、class常量池和运行时常量池

2.1.7 直接内存

直接内存并不是虚拟机运行时数据区的一部分,也不是虚拟机规范中定义的内存区域,但是这部分内存也被频繁地使用。而且也可能导致OutOfMemoryError异常出现。

JDK1.4中新加入的 NIO(New Input/Output) 类,引入了一种基于通道(Channel) 与缓存区(Buffer) 的 I/O 方式,它可以直接使用Native函数库直接分配堆外内存,然后通过一个存储在 Java 堆中的 DirectByteBuffer 对象作为这块内存的引用进行操作。这样就能在一些场景中显著提高性能,因为避免了在 Java 堆和 Native 堆之间来回复制数据。

本机直接内存的分配不会受到 Java 堆的限制,但是,既然是内存就会受到本机总内存大小以及处理器寻址空间的限制。

2.2、Java内存模型-JMM(*):和Java并发编程有关

2.3、Java对象模型:和Java对象在虚拟机中的表现形式有关

Java对象模型
HotSpot虚拟机中,设计了一个OOP-Klass Model。OOP(Ordinary Object Pointer)指的是普通对象指针,而Klass用来描述对象实例的具体类型。每一个Java类,在被JVM加载的时候,JVM会给这个类创建一个instanceKlass,保存在方法区,用来在JVM层表示该Java类。当我们在Java代码中,使用new创建一个对象的时候,JVM会创建一个instanceOopDesc对象,这个对象中包含了对象头以及实例数据。

3、JMM是什么?

JMM-Java内存模型是和多线程相关的,它描述了一组规则或规范,这个规范定义了一个线程对共享变量的写入时对另一个线程是可见的。Java的多线程之间是通过共享内存进行通信的,而由于采用共享内存进行通信,在通信过程中会存在一系列如可见性、原子性、顺序性等问题,而JMM就是围绕着多线程通信以及与其相关的一系列特性而建立的模型。JMM定义了一些语法集,这些语法集映射到Java语言中就是volatilesynchronizedfinal等关键字。正是由于有了JMM,Java的并发编程才能避免很多问题。
JMM是一种规范,目的是解决由于多线程通过共享内存进行通信时,存在的本地内存数据不一致、编译器会对代码指令重排序、处理器会对代码乱序执行等带来的问题
JMM性质:重排序、可见性、原子性

3.1、什么是重排序?

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

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

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

            two.start();
            one.start();
            latch.countDown();
            one.join();
            two.join();
            String result = "第" + i + "次(" + x + "," + y + ")";

            /*if (x == 1 && y == 1) {
                System.out.println(result); //多次执行可以达到 x = 1, y = 1
                break;
            } else {
                System.out.println(result);
            }*/

            if (x == 0 && y == 0) {
                System.out.println(result); //多次执行可以达到 x = 0, y = 0
                break;
            } else {
                System.out.println(result);
            }
        }
    }
}

运行结果:第第1641次次(0,0),发生重排序了:执行顺序可能是:y=a; -> a=1; -> x=b; -> b=1;
原本线程顺序:线程1先给a赋值,再给x赋值;而线程2先给b赋值,再给y赋值;

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

在这里插入图片描述

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

  1. 编译器优化编译器(包括JVM,JIT编译器等)出于优化的目的(例如当前有了数据a,那么如果把对a的操作放到一起效率会更高,避免了读取b后又返回来重新读取a的时间开销),在编译的过程中会进行一定程度的重排,导致生成的机器指令和之前的字节码的顺序不一致。在刚才的例子中,编译器将y=a和b=1这两行语句换了顺序(也可能是线程2的两行换了顺序,同理),因为它们之间没有数据依赖关系,那就不难得到 x =0,y = 0 这种结果了;
  2. 指令重排序CPU 的优化行为,和编译器优化很类似,是通过乱序执行的技术,来提高执行效率。所以就算编译器不发生重排,CPU 也可能对指令进行重排,所以我们开发中,一定要考虑到重排序带来的后果。
  3. 内存的“重排序”:内存系统内不存在重排序,但是内存会带来看上去和重排序一样的效果,所以这里的“重排序”打了双引号。由于内存有缓存的存在,在JMM里表现为主存和本地内存,由于主存和本地内存的不一致,会使得程序表现出乱序的行为。 在刚才的例子中,假设没编译器重排和指令重排,但是如果发生了内存缓存不一致,也可能导致同样的情况:线程1 修改了 a 的值,但是修改后并没有写回主存,所以线程2是看不到刚才线程1对a的修改的,所以线程2看到a还是等于0。同理,线程2对b的赋值操作也可能由于没及时写回主存,导致线程1看不到刚才线程2的修改——引出可见性

3.4、可见性

可见性是指某线程修改共享变量的指令对其他线程来说都是可见的,它反映的是指令执行的实时透明度。
可见性指的是确实有一个东西存在,但是其他的人看不到我,其他线程无法感知到我内容的变化。

每个线程都有独占的内存区域, 如操作栈、本地变量表等。线程本地内存保存了引用变量在堆内存中的副本, 线程对变量的所有操作都在本地内存区域中进行, 执行结束后再同步到堆内存中去。这里必然有一个时间差, 在这个时间差内,该线程对副本的操作, 对于其他线程都是不可见的。

public class FieldVisibility {
    //volatile 强制每一次读取的时候,读取到的都是线程已经修改过的最新的值
    int a = 1;
    int b = 2;
    //只需要在 变量b 增加 volatile
    //后面想读取b的时候就能看到b写入之前的所有操作,其中就包括a=3,所以在读取b的时候就一定能看到a的最新情况。

    private void print() {
        System.out.println("b= " + b + ", a = " + a);
    }

    private void change() {
        a = 3;
        b = 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=2
a=1,b=2
a=3,b=3

b= 3, a = 1(罕见):发生可见性问题
b看到了真实的值是3,但是a还没完全同步过来,只能找原始的a也就是1

解决方法volatile修饰变量——volatile强制写入主存。

3.4.1、为什么会有可见性问题?

主存中的数据不是最新的,导致线程通信不一致。由于CPU有多级缓存,导致读的数据可能会过期:

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

3.4.2、JMM的抽象:主内存和本地内存(*)

3.4.2.1、什么是主内存和本地内存?

在这里插入图片描述
在这里插入图片描述
Java 作为高级语言,屏蔽了CPU多层缓存这些底层细节,用 JMM 定义了一套读写内存数据的规范,虽然我们不再需要关心一级缓存和二级缓存的问题,但是,JMM 抽象了主内存和本地内存的概念。
这里说的本地内存并不是真的是一块给每个线程分配的内存,而是 JMM 的一个抽象,是对于寄存器、一级缓存、二级缓存等的抽象

3.4.2.2、主内存和本地内存的关系

JMM有以下规定

  1. 所有的变量都存储在主内存中,同时每个线程也有自己独立的工作内存,工作内存中的变量内容是主内存中的拷贝;
  2. 线程不能直接读写主内存中的变量,而是只能操作自己工作内存中的变量,然后再同步到主内存中;
  3. 主内存是多个线程共享的,但是线程间不共享工作内存,如果线程间需要通信,必须借助主内存中转来完成;
    总结:所有的共享变量存在于主内存中,每个线程有自己的本地内存,而且线程读写共享数据也是通过本地内存交换的,所以才导致了可见性问题。

3.4.3、Happens-Before原则

什么是Happens-Before?
Happens-Before规则是用来解决可见性问题的:在时间上,动作A发生在动作B之前,B保证能看见A,这就是Happens-Before。两个操作可以用Happens-Before来确定它们的执行顺序,如果一个操作Happens-Before于另一个操作,那么我们说第一个操作对于第二个操作是可见的。

3.4.3.1、Happens-Before规则有哪些?
  1. 单线程规则
  2. 锁操作(synchronized和Lock)(*)
  3. volatile变量(*)FieldVisibility.java
  4. 线程启动
  5. 线程join
  6. 传递性
  7. 中断
  8. 构造方法
  9. 工具类的Happens-Before原则:
  10. 线程安全稳定容器get一定能看到在此之前的put等存入动作
  11. CountDownLatch
  12. Semaphore
  13. Future
  14. 线程池
  15. CyclicBarrier

4、volatile关键字:和synchronized一样,在并发中起到保护作用的关键字

4.1、volatile是什么?

  1. volatile是一种轻量级的同步方式;
  2. 如果一个变量被修饰成volatile,那么JVM就知道了这个变量可能会被并发修改;

volatile 的英文本义是“ 挥发、不稳定的” , 延伸意义为敏感的。当使用volatile修饰变量时,意昧着任何对此变量的操作都会在内存中进行,不会产生副本,以保证共享变量的可见性,局部阻止了指令重排的发生。

4.2、volatile的适用场景

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

public class UseVolatile1 implements Runnable {
    volatile boolean done = false;
    AtomicInteger realA = new AtomicInteger();

    public static void main(String[] args) throws InterruptedException {
        UseVolatile1 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;
    }
}

场景二:作为刷新之前变量的触发器。

4.2、volatile的两点作用

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

4.3、volatile和synchronized的关系

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

4.4、用volatile修正重排序问题

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

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

            two.start();
            one.start();
            latch.countDown();
            one.join();
            two.join();
            String result = "第" + i + "次(" + x + "," + y + ")";

            /*if (x == 1 && y == 1) {
                System.out.println(result); 
                break;
            } else {
                System.out.println(result);
            }*/

            if (x == 0 && y == 0) {
                System.out.println(result); 
                break;
            } else {
                System.out.println(result);
            }
        }
    }
}

4.5、小结

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

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

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

  1. synchronized不仅保证了原子性,还保证了可见性;
  2. synchronized不仅让被保护的代码安全,还近朱者赤,也就是说保证了之前的代码写入主存。

5、原子性

5.1、什么是原子性?

一系列的操作,要么全部执行成功,要么全部不执行,不会出现执行一半的情况,是不可分割的。
比如:ATM机取钱。但是 i++ 不是原子性的 可以用synchronized实现原子性。

5.2、Java中的原子操作有哪些?(*)

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

5.3、long和double的原子性————非原子化处理操作

在32位的JVM上,long和double的操作不是原子的,但是在64位的JVM是原子的。

5.4、原子操作 + 原子操作 != 原子操作

简单的把原子操作组合在一起,并不能保证整体依然具有原子性。

6、常见问题

6.1 JMM应用实例:单例模式8种写法,单例和并发的关系(*)

单例模式

6.2 为什么需要单例?

节省内存和计算、保证结果正确、方便管理
适用场景
a、无状态的工具类:比如日志工具类,不管是在哪里使用,我们需要的只是它帮我们记录日志信息,除此之外,并不需要在它的实例对象上存储任何状态,这时候我们就只需要一个实例对象即可;
b、全局信息类:比如我们在一个类上记录网站的访问次数,我们不希望有的访问被记录在对象A上,有的却记录在对象B上,这时候我们就让这个类成为单例。

6.3 单例模式常见问题

饿汉式的缺点?
饿汉式确实具有写法简单,线程安全的优点,但是上来就会把资源加载进来,有的时候我们不需要这实例,它也会加载,造成一定情况的浪费。
懒汉式的缺点? 写法相对复杂,线程不安全。

6.4 为什么要用双重检查模式(double-check)?不用就不安全吗?

a、线程安全;b、性能问题,多线程访问不能及时响应
为什么双重检查模式要用volatile? 避免重排序
instance=new Singleton();这段代码其实是分三步执行:
1、为instance分配内存空间;
2、初始化instance
3、将instance指向分配的内存地址。
但是由于 JVM 具有指令重排的特性,执行顺序有可能变成 1->3->2。指令重排在单线程环境下不会出现问题,但是在多线程环境下会导致一个线程获得还没有初始化的实例。例如,线程 T1 执行了 1 和 3,此时 T2 调用 getInstance() 后发现 instance 不为空,因此返回 instance,但此时 instance还未被初始化。
使用volatile可以禁止 JVM 的指令重排,保证在多线程环境下也能正常运行。

6.5. 讲一讲什么是Java内存模型?

JMM是一种规范,目的是解决由于多线程通过共享内存进行通信时,存在的本地内存数据不一致、编译器会对代码指令重排序、处理器会对代码乱序执行等带来的问题
Java内存模型
Java内存模型
什么是Java内存模型?

6.6. volatile和synchronized的异同?

synchronized 关键字和 volatile 关键字是两个互补的存在,而不是对立的存在!

  • volatile 关键字是线程同步的轻量级实现,所以volatile性能肯定比synchronized关键字要好。但是volatile 关键字只能用于变量而 synchronized 关键字可以修饰方法以及代码块
  • volatile 关键字能保证数据的可见性,但不能保证数据的原子性。synchronized 关键字两者都能保证。
  • volatile关键字主要用于解决变量在多个线程之间的可见性,而 synchronized 关键字解决的是多个线程之间访问资源的同步性。
    volatile和synchronized到底啥区别

6.7. 什么是原子操作?Java中有哪些原子操作?

原子操作就是: 不可中断的一个或者一系列操作, 也就是不会被线程调度机制打断的操作, 运行期间不会有任何的上下文切换(context switch)。
Java中的原子操作:
a. 除long和double之外的基本类型(int、byte、boolean、short、char、float)
b. 所有引用reference的赋值操作,不管是32位的机器还是64位的机器
c. java.concurrent.Atomic.* 包中所有类的原子操作

6.8. 生成对象的过程是不是原子操作?

不是

6.9. 为什么会有内存可见性问题?什么是内存可见性问题?

Java内存模型之可见性

6.10. Java代码如何一步步转化,最终被CPU执行?

(java代码到CPU的过程)

6.11. 单例模式的作用和适用场景

单例模式8种写法
设计模式——单例模式

6.12. 实际开发应该选择哪种单例的实现方案?为什么?

枚举 可以防止反序列化重新创建新的对象

6.13. 什么是happens-before?规则有哪些?

Java内存模型之可见性
有序性可见性,Happens-before来搞定

6.14. 讲讲volatile关键字?适用场合?作用?

当一个变量被声明为 volatile 时,线程在【读取】共享变量时,会先清空本地内存变量值,再从主内存获取最新值;线程在【写入】共享变量时,不会把值缓存在寄存器或其他地方(就是所谓的「工作内存」),而是会把值刷新回主内存。volatile 能保证内存可见性、解决重排序,但是不能保证原子性。

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

volatile深入

6.15. 什么是主内存和本地内存?二者关系

Java 作为高级语言,屏蔽了CPU多层缓存这些底层细节,用 JMM 定义了一套读写内存数据的规范,虽然我们不再需要关心一级缓存和二级缓存的问题,但是,JMM 抽象了主内存和本地内存的概念。 这里说的本地内存并不是真的是一块给每个线程分配的内存,而是 JMM 的一个抽象,是对于寄存器、一级缓存、二级缓存等的抽象。
二者关系
a. 所有的变量都存储在主内存中,同时每个线程也有自己独立的工作内存,工作内存中的变量内容是主内存中的拷贝;
b. 线程不能直接读写主内存中的变量,而是只能操作自己工作内存中的变量,然后再同步到主内存中;
c. 主内存是多个线程共享的,但是线程间不共享工作内存,如果线程间需要通信,必须借助主内存中转来完成;
总结:所有的共享变量存在于主内存中,每个线程有自己的本地内存,而且线程读写共享数据也是通过本地内存交换的,所以才导致了可见性问题。

6.16.JVM内存结构 VS Java内存模型 VS Java对象模型

JVM内存结构 VS Java内存模型 VS Java对象模型
JVM内存结构,由Java虚拟机规范定义。描述的是Java程序执行过程中,由JVM管理的不同数据区域。各个区域有其特定的功能。
Java内存模型:JMM是和多线程相关的,它描述了一组规则或规范,这个规范定义了一个线程对共享变量的写入时对另一个线程是可见的。Java的多线程之间是通过共享内存进行通信的,而由于采用共享内存进行通信,在通信过程中会存在一系列如可见性、原子性、顺序性等问题,而JMM就是围绕着多线程通信以及与其相关的一系列特性而建立的模型。JMM定义了一些语法集,这些语法集映射到Java语言中就是volatile、synchronized等关键字。正是由于有了JMM,Java的并发编程才能避免很多问题。
Java对象模型:Java是一种面向对象的语言,而Java对象在JVM中的存储也是有一定的结构的。而这个关于Java对象自身的存储模型称之为Java对象模型。 HotSpot虚拟机中,设计了一个OOP-Klass Model。OOP(Ordinary Object Pointer)指的是普通对象指针,而Klass用来描述对象实例的具体类型。每一个Java类,在被JVM加载的时候,JVM会给这个类创建一个instanceKlass,保存在方法区,用来在JVM层表示该Java类。当我们在Java代码中,使用new创建一个对象的时候,JVM会创建一个instanceOopDesc对象,这个对象中包含了对象头以及实例数据。

下一章:第十二章 死锁——从产生到消除

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值