Java内存模型原理小结

本文深入探讨Java并发编程的关键概念,包括JMM内存模型、volatile关键字的使用、原子性及单例模式的线程安全实现,揭示多线程环境下变量可见性、重排序等问题的解决方案。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

目录

1. Java代码到CPU指令

 2. JVM内存结构/Java内存模型/Java对象模型

2.1 JMM之重排序

2.2 JMM之可见性

2.3 主内存和本地内存

2.4 Happens-Before原则

2.5 volatile关键字

2.5.1 volatile 不适用于a++

2.5.2 volatile适用于纯赋值操作

2.5.3 volatile适用于刷新之前变量的触发器

2.5.4 volatile的两点作用

2.5.5 volatile和synchronized的关系

2.5.6 volatile小结

3. 原子性

4. 单例模式与JMM的联系

4.1 饿汉式写法

4.1.1 饿汉式静态常量

4.1.2 饿汉式静态代码块

4.2 不可用的线程不安全的懒汉式

4.3 线程安全的同步代码块的懒汉式

4.4 线程不安全的同步代码块的懒汉式

4.5 推荐使用:双重检查

 4.6 推荐使用:静态内部类写法

4.7 推荐使用:枚举写法

4.8 单例模式总结

5. 面试常见问题

5.1 什么是Java内存模型?

5.2 volatile和synchronized的异同?

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

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

5.5 为什么会有内存可见性问题?

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

5.7 synchronized对于可见性问题的作用


1. Java代码到CPU指令

 

 

 2. JVM内存结构/Java内存模型/Java对象模型

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

下图把整个Java虚拟机的运行时区域分为5个部分,这就是JVM内存结构。

 

Java对象模型:

  • Java对象自身的储存模型;
  • JVM会给这个类创建一个instanceKlass,保存在方法区,用来在JVM层表示该Java类;
  • 当我们在Java代码中,使用new创建一个对象的时候,JVM会创建一个instanceOopDesc对象,这个对象中包含了对象头以及实例数据。

 

Java内存模型,就是JMM。JMM实际上是一种规范,需要各个JVM的实现来遵守JMM规范,以便于开发者可以利用这些规范,更方便地开发多线程程序。

如果没有JMM,那么很可能经过了不同JVM(比如OpenJDK,Oracle的JDK)的不同规则的重排序之后,会导致不同的虚拟机上运行的结果不一样。

此外,JMM是工具类和关键字的原理。

  • volatile、synchronized、Lock等的原理都是JMM;
  • 通过JMM,开发者只需要用同步工具和关键字就可以开发并发程序。

 

JMM最重要的3点内容分别是:重排序、可见性、原子性。

 

2.1 JMM之重排序

 

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

第一次输出:

x = 0, y = 1

稍微换一下线程的启动顺序:

 得到结果:

 

 

再通过工具类使得两个线程同时开始:

import java.util.concurrent.CountDownLatch;

/** 演示重排序,“直到达到某个条件才停止” */
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);
                break;
            }else {
                System.out.println(result);
            }
        }
    }
}

第N次执行时候出现 :

x=1,y=1

 

所以,什么是重排序:

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

 

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

 

 

2.2 JMM之可见性

/** 演示可见性问题 */
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) {
        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();
    }
}

会出现 b=3, a=1的情况,

原因是:线程2看了b=3,但是a还没有从线程1同步过来,线程2之看到了初始化时的a=1,这就是可见性问题。

写线程和读线程只能通过共享内存来进行通讯,但可能存在延迟的情况,这就导致了可见性问题的产生。

解决:使用volatile 关键字强迫程序每次都去读取最新的值。

原理:

一旦写线程把x改为1,在读线程读取之前,volatile就会强制行的把x=1更新(flush)到主内存中。

 

为什么会有可见性问题?

因为从内存到CPU之间有多层缓存,从下到上,容量递减,速度递增,不同层次的缓存要拿到对方最新的数据,是有延迟的。

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

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

 

2.3 主内存和本地内存

不同的工作线程都有属于自己的独立工作内存,这些独立的内存之间是不共通的,它们都过Buffer连接主内存进行通讯。

 

主内存和本地内存的关系如何?

JMM有以下规定:

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

 

总结:

所有的共享变量存在与主内存中,每个线程有自己的本地工作内存,而且线程读写共享数据也是通过工作内存交换,但是存在延迟的情况,所以导致可见性问题的出现。

 

2.4 Happens-Before原则

 

2.5 volatile关键字

如果对于一个基本变量直接赋值,那就是原子操作。

 

 

2.5.1 volatile 不适用于a++

代码演示:

import java.util.concurrent.atomic.AtomicInteger;

/** volatile不适用a++ */
public class NoVolatile implements Runnable{

    volatile int a ;
    AtomicInteger realA = new AtomicInteger();

    @Override
    public void run() {
        for (int i= 0 ; i <10000; i++){
            a++;
            realA.incrementAndGet();
        }
    }

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

输出:

volatile 修饰的变量或者布尔值,不能依赖以前的状态,否则volatile失效,因为volatile不能做到原子保护。

 

2.5.2 volatile适用于纯赋值操作

代码演示:

import java.util.concurrent.atomic.AtomicInteger;

/** volatile适用场景1 */
public class UseVolatile1 implements Runnable{

    volatile boolean done = false ;
    AtomicInteger realA = new AtomicInteger();

    @Override
    public void run() {
        for (int i= 0 ; i <10000; i++){
            setDone();
            realA.incrementAndGet();
        }
    }

    private void setDone() {
        done = true;
    }

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

输出:

小结:

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

 

2.5.3 volatile适用于刷新之前变量的触发器

 

2.5.4 volatile的两点作用

2.5.5 volatile和synchronized的关系

volatile 可以看做是轻量版的synchronized:

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

 

2.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可以使得long和double的赋值是原子的。

 

3. 原子性

原子操作+原子操作 != 原子操作,即原子操作的组合不具备原子性。

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

 

4. 单例模式与JMM的联系

单例模式的作用:节省内存和计算,保证结果正确,方便管理。

单例模式适用场景:

4.1 饿汉式写法

优点:写法比较简单,类装载的时候就完成了实例化,

 

4.1.1 饿汉式静态常量

/** 单例模式:饿汉式写法(静态常量) */
public class Singleton1 {

    /** 由于static的缘故,根据JVM的规定,会在加载这个类的时候把INSTANCE实例完毕,
      这就避免了线程同步问题
     */
    private final static Singleton1 INSTANCE = new Singleton1();

    /** 单例模式的构造函数都是私有的,这是希望外界不会来调用 */
    private Singleton1(){

    }

    /** 给外界获得单例模式的方法 */
    public static Singleton1 getInstance(){
        return INSTANCE;
    }
}

 

4.1.2 饿汉式静态代码块

/** 单例模式:饿汉式(静态代码块) */
public class Singleton2  {

    //不做初始化
    private final static Singleton2 INSTANCE;

    //在代码块中做初始化,同样可以通过JVM保证线程安全
    static {
        INSTANCE = new Singleton2();
    }

    //单例模式的构造函数都是私有的,这是希望外界不会来调用 
    private Singleton2(){

    }
    //给外界获得单例模式的方法
    public static Singleton2 getInstance(){
        return INSTANCE;
    }
}

 

4.2 不可用的线程不安全的懒汉式

/** 懒汉式写法,线程不安全 */
public class Singleton3 {

    private static Singleton3 instance;

    private Singleton3(){

    }

    public static Singleton3 getInstance(){
        /** 要用的时候才初始化。但是如果两个线程同时运行到达这一句,都判断是null,
            这时线程1执行 instance = new Singleton3(),线程2也同时 instance = new Singleton3()
            并把实例返回。
            这种情况下就会多次创建实例,不再符合单例的要求,所以这种写法不可用,线程不安全。
         * */
        if (instance == null){
            instance = new Singleton3();
        }
        return instance;
    }
}

 

4.3 线程安全的同步代码块的懒汉式

加上synchronized同步方法,但是不推荐使用,虽然安全,但是效率太低。

/** 单例模式:线程安全,但是不推荐 */
public class Singleton4 {

    private static Singleton4 instance;

    private Singleton4(){

    }

    //synchronized虽然安全,但是效率太低,很多线程不能同时进入这个方法
    public synchronized static Singleton4 getInstance(){
        if (instance == null){
            instance = new Singleton4();
        }
        return instance;
    }
}

 

4.4 线程不安全的同步代码块的懒汉式

也不推荐使用。

public class Singleton5 {
    private static Singleton5 instance;
    private Singleton5(){

    }

    public  static Singleton5 getInstance(){
        if (instance == null){
            /** 意图把需要保护的地方用synchronized同步,但是一旦多个线程来到这一步,
                就无法阻止多个线程创建多个实例。
                线程1获得锁运行完之后释放锁,线程2获得锁再赋值一次,这就创建了多个实例,
                所以线程不安全。
             */
            synchronized (Singleton5.class){
                instance = new Singleton5();
            }
        }
        return instance;
    }
}

 

4.5 推荐使用:双重检查

有点:线程安全,延迟加载,效率高,使用了volatile。

/**  单例模式:双重检查 */
public class Singleton6 {
    /** 这是使用了volatile,真正保证了安全 */
    private volatile static Singleton6 instance;
    private Singleton6(){

    }

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

为什么要double-check(双重锁)?

答:线程安全。

为什么这里要用volatile修饰?

答:Java中新建对象不是原子操作,新建对象实际上有3个步骤。第一,先创建空的对象,第二,调用构造方法,可能会有复杂的计算(访问数据库之类的),第三,把创建好的实例赋值给等号左边的引用,这样其他线程就能用这个引用了。

但是,对于CPU和编译器而言,它们是有重排序功能的,我们不知道它们是否会把这个3个步骤进行重排序,因为它们完全可以将其顺序颠倒。如下图所示就是一个颠倒之后的效果,比如先创建一个空的对象,然后直接赋值给左边的引用,最后才去调构造方法去做一些初始化工作。

假设CPU做了这样的重排序,线程1进来,执行完语句之后,左边的引用rs已经不是空了,但本身这个对象虽然不是空的,可对象里面的各种属性可能是没有经过计算和赋值。如果此时线程2进来,检查左边的引用rs是否是空,那么线程2只能看到左边的引用不是空的,rs虽然不是空的,但其内部还没有完全准备完毕,此时线程2会直接跳过新建的过程,直接返回rs,随后就会产生空指针问题(NPE)

所以要使用volatile修饰,以防止重排序出现。

 

 4.6 推荐使用:静态内部类写法

懒汉式,静态内部类写法,可用,满足同时保证了线程安全,和懒加载的优点,效率也不错。

/** 单例模式:懒汉式,静态内部类写法,可用,效率也不错 */
public class Singleton7 {

    private Singleton7(){

    }

    //静态内部类
    private static class SingletonInstance{
        /**只有当真正休要调用getInstance(),才会初始化这个实例。
          由于JVM内的加载性质,保证了即便多个线程同时去访问“Singleton7 INSTANCE”这个对象,
          也不会创建多个实例了,这样就同时保证了线程安全,和懒加载的优点。
         */
        private static final Singleton7 INSTANCE = new Singleton7();
    }
    
    public  static Singleton7 getInstance(){
        return SingletonInstance.INSTANCE;
    }
}

 

4.7 推荐使用:枚举写法

枚举写法实际上是生产实践中最佳的单例模式的写法!!!

/** 单例模式最佳写法:枚举,枚举本质还是一个类 */
public enum Singleton8 {
    INSTANCE;

    public void whatever(){
        /** whatever()代表这个类中的一些方法 */
    }
}

 

4.8 单例模式总结

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

 

5. 面试常见问题

5.1 什么是Java内存模型?

答:

  1. C语言没有内存模型,但是没有会造成很多的问题和混乱,会导致在不同的处理器上程序执行的结果不一样,无法保证并发安全,所以需要一个规范来定义标准,让多线程程序的运行结果真正做到可预期;
  2. 所以Java内存模型,也就是JMM它是一组规范,用来帮助CPU、JVM以及开发者之间进行很好的合作,能够更好的避免线程安全问题,开发者利用JMM规范可以更快更方便地开发多线程程序;
  3. 如果没有JMM作为规范,那么很可能经过了不同地JVM的不同规则的重排序之后,导致不同的虚拟机上运行的结果不一样;
  4. 并且,JMM还是volatile、synchronized、Lock等的原理基础,有了JMM,开发者就只需要用同步工具和关键字就可以开发;
  5. 此外,JMM最重要的3点内容就是重排序、内存可见性、和原子性;
  6. 重排序是指代码实际执行顺序和代码在Java文件中的顺序不一致;重排序有其优点,比如提高处理速度;重排序有3种情况,分别是编译器优化,指令重排序,内存的“重排序”(强调有双引号)
  7. 其中,指令重排序是CPU的优化行为,和编译器优化很相似,是通过乱序执行的技术,来提高执行效率;所以就算编译器不发生重排序,CPU也可能对指令进行重排序,所以开发中要考虑到重排序带来的问题和后果;
  8. 实际上内存系统不存在重排序,但是内存会带来看上去和重排序很像的效果;因为由于内存有缓存的存在,这在JMM表现位主存和(线程运行专属的)本地内存,由于主存和本地内存的不一致,会使得程序表现出乱序的行为;
  9. 比如:如果发现内存缓存不一致,比如有个变量a=0,线程1修改了a的值,但是修改后没有及时写回主存,所以线程2看不到刚刚线程1对a的修改,认为a还是等于0;同样道理,线程2修改了b的值,线程1也看不到线程2的修改;由此引出内存可见性问题;
  10. 为什么会有可见性问题呢,因为CPU为了加快执行速度,在内存和CPU之间,加了很多缓存层;这些缓存层分别会把下面一级的缓存的部分内容取过来,随着层次的逐渐上升,逐渐接近CPU,速度也越快,容量也越小;正是因为多个CPU之间,它们不同时共享高速缓存,所以就导致了可见性问题;这就引出了JMM对于整个内存的抽象;
  11.  Java作为高级语言,屏蔽了很多底层细节,并且用JMM定义了一套读写内存数据的规范,抽象了主内存和本地内存的概念;主要抽象为两个部分,最主要的是主内存部分,所有的变量都是存储在主内存中,而每一个线程都会有自己独立的工作内存,工作内存中的变量内容是主内存中的拷贝;线程与线程之间不能直接进行通讯,要通过主内存中转来完成,所以线程读写共享数据也是通过这样的方式完成,就导致了可见性问题;
  12. 简单来说,线程间对于共享变量的可见性问题是由多级缓存引起,如果所有的核心都只用一个缓存,就不会出现可见性问题;
  13. 解决可见性问题,需要一个happens-before原则,也就是在连续的时间上,动作A发生在动作B之前,B能保证看见A,这就是happens-before原则;
  14. 原子性

 

5.2 volatile和synchronized的异同?

volatile 可以看做是轻量版的synchronized:

如果一个共享变量自始至终只被各个线程赋值,而没有其他的操作,那么就可以用volatile来替代synchronized或者代替原子变量,因为赋值自身是原子性的,而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可以使得long和double的赋值是原子的。

 

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

 

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

生成对象的过程:

1)新建一个空的Person对象;

2)把这个对象的地址指向P;

3)执行Person的构造函数。

这三个过程不能保证它们的原子性。

 

5.5 为什么会有内存可见性问题?

多层缓存会造成数据同步延迟的问题。

 

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

答:Java并没有规定它们一定是原子的,因为它们是64位的,写入的时候,可能会出现前32位和后32位错位的情况。但是在实际的商用JDK中,这个问题已经被考虑过并且解决了。

 

5.7 synchronized对于可见性问题的作用

synchronized也可以达到happens-before的效果。synchronized不仅防止了一个线程在操作某对象时受到其他线程的干扰,同时还保证了修改之后,可以立即被其他线程所看到,因为如果其他线程看不到,那也会有线程安全问题。

 

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值