Java内存模型,及其相关面试问题

一、计算机内存模型

计算机在执行程序时,每条指令都是在CPU中执行的,而执行指令过程中,势必会涉及到数据的读取和写入。
由于在程序运行过程中,临时数据是存放在主存(物理内存)中的,这时就存在一个问题,由于CPU执行指令的速度很快,而从内存读取和写入数据的过程与其相比速度要慢得多,因此如果任何时候对数据的操作都要通过和内存的交互来进行,会大大降低指令执行的速度。

因此CPU中就有了高速缓存:当程序在运行过程中,会将运算需要的数据从主存复制一份到CPU的高速缓存中,当CPU进行计算时就可以直接从它的高速缓存中读取和谢图数据,运算结束之后,再将高速缓存中的数据刷新到主存当中去。

举例:

i = i + 1

单线程:当线程执行该语句时,会从主存中读取i的值,然后复制一份放置到高速缓存中,CPU执行指令对i进行加1操作后,将数据写入到高速缓存中,最后将高速缓存中i最新的值刷新到主存当中。
多线程:多核CPU中,每条线程可能运行于不同的CPU中,因此每个线程运行时有自己的高速缓存
多核CPU情况下可能会出现缓存一致性问题:初始时,有两个线程分别读取i的值存入各自所在的CPU的高速缓存当中,线程1进行加1操作后,把最新的i值写入到内存;此时线程2的高速缓存中i的值还是0,进行加1操作后,i的值为1,然后把线程2把i的值写入内存。最终结果i的值是1,而不是2。(通常这种被多个线程访问的变量为共享变量)

缓存一致性问题的两种解决方法

1. 通过在总线加LOCK#锁的方式
早期CPU中,是通过在总线上加LOCK#锁的形式来解决缓存不一致的问题。因为CPU和其他部件进行通信都是通过总线来进行的,如果对总线加LOCK#的化,也就是说阻塞了其他CPU对其他部件访问(如内存),从而使得只能有一个CPU能使用这个变量的内存。
在上面的例子中,若一个线程在执行i=i+1时,在总线上发出了LOCK#锁的信号,那么只有等待这段代码完全执行完毕之后,其他CPU才能从变量i所在的内存读取变量,然后进行相应的操作。
但,由于在锁住总线期间,其他CPU无法访问内存,导致效率低下。
所以出现了第二种方法。

2. 通过缓存一致性协议
比较有名的是Intel的MESI协议,MESI协议保证了每个缓存中使用的共享变量的副本是一致的。
它的核心思想是:发现共享变量,则直接从内存读取。
当CPU写数据时,若发现操作的变量是共享变量,即在其他CPU中也存在该变量的副本,会发出信号通知其他CPU将该变量的缓存行置为无效状态,因此当其他CPU需要读取这个变量时,发现自己缓存中缓存该变量的缓存行是无效的,那么它就会从内存重新读取。

以上是两种在硬件层面上解决的方式。

缓存一致性

二、Java内存模型

在Java虚拟机规范中试图定义一种Java内存模型(Java Memory Model,JMM)来屏蔽各个硬件平台和操作系统的内存访问差异,以实现让Java程序在各种平台下都能达到一致的内存访问效果。

为了获得较好的执行性能,Java内存模型并没有限制执行引擎使用处理器的寄存器,或高速缓存来提升指令执行速度,也没有限制编译器对指令进行重排序。即,在Java内存模型中,也会存在缓存一致性问题和指令重排序问题。

Java内存模型规定所有的变量都是存在主存当中(类似上述的物理内存),每个线程都有自己的工作内存(类似上述的高速缓存)。线程对变量的所有操作都必须在工作内存中进行,而不能直接对主存进行操作。并且每个线程不能访问其他线程的工作内存。

如:i=10;
在Java中执行上述语句,执行线程必须先在自己的工作线程中对变量i所在的缓存行进行赋值操作,然后再写入到主存当中。而不是直接将数值10写入到主存当中。

  • 原子性:一个操作或多个操作,要么全部执行并且执行过程中不会被任何因素打断,要么就不执行
    Java中,对基本数据类型变量的读取和赋值操作是原子性操作,即这些操作不可被中断,要么执行,要么不执行
    若需要实现更大范围操作的原子性,可以通过synchronizedLock来实现。由于synchronized和Lock能够保证任一时刻只有一个线程执行该代码块,那么自然就不存在原子性问题了,从而保证了原子性。
    如:
x = 10; //是  数值直接赋值,执行会直接写入到工作内存中
y = x; //不是 两个操作:读取x值 + x值写入工作内存
x++; //不是  三个操作:读取x值 + 加1操作 + 写入
x = x+1; //不是 三个操作:同上

也就是说,仅有简单的读取、赋值(而且必须是将数字赋值给某个变量,变量之间的互相赋值不是原子性操作)才是原子性操作。

  • 可见性:指当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值
    Java提供了volatile关键字来保证可见性。**当一个共享变量被volatile修饰时,它会保证修改的值会立即被更新到主存,当有其他线程需要读取时,它会去内存中读取新值。**而普通的共享变量不能保证可见性,因为普通共享变量被修改之后,什么时候写入主存是不确定的,当其他线程去读取时,此时内存中可能还是原来的旧值,因此无法保证可见性。
    通过synchronized和Lock也能保证可见性synchronizedLock能保证同一时刻只有一个线程获取锁然后执行同步代码,并且在释放锁之前会将对变量的修改刷新到主存当中。因此可以保证可见性。

  • 有序性:程序执行的顺序按照代码的先后顺序执行
    指令重排序:一般来说,处理器为了提高程序运行效率,可能会对输入代码进行优化,它不保证程序中各个语句的执行先后顺序同代码中的顺序一致,但是它会保证程序最终执行结果和代码顺序执行的结果是一致的。(顺序不一致,但结果一致)
    但,Java内存模型中允许编译器和处理器对指令重排序,其重排序过程不会影响到单线程程序的执行,却会影响到多线程并发执行的正确性。
    保证有序性方法:Java中,①可以通过volatile关键字来保证一定的“有序性”;②可通过synchronized和Lock来保证有序性,其可以保证每个时刻是有一个线程执行同步代码,相当于是让线程顺序执行同步代码,自然也就保证了有序性;③Java内存模型具备一些先天的“有序性”,即不需要通过任何手段就能够得到保证的有序性,通常成为happens-before原则

若两个操作的执行次序无法从happens-before原则推导出来,那么它们就不能保证它们的有序性,虚拟机可以随意地对它们进行重排序。
happens-before原则(先行发生原则):

  • 程序次序规则:一个线程内,按照代码顺序,书写在前的操作线性发生于先行在后的操作
  • 锁定规则:一个unlock操作先行发生于后面对同一个锁的lock操作
  • volatile变量规则:对一个变量的写操作先行发生于候面对这个变量的读操作
  • 传递规则:若操作A先行发生于操作B,而操作B又先行发生于操作C,则可以得出操作A先行发生于操作C
  • 线程启动规则:Thread对象的start()方法先行发生于此线程的每一个动作
  • 线程中断规则:对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生
  • 线程终结规则:线程中所有的操作都先行发生于线程的终止检测,我们可以通过Thread.join()方法结束、Thread.isAlive()的返回值手段检测到线程已经终止执行
  • 对象终结规则:一个对象的初始化完成先行发生于它的finalize()方法的开始

Runtime Data Area

程序执行过程中,JVM会用一段空间来存储程序执行期间需要用到的数据和相关信息,这段空间一般被称作为Runtime Data Area(运行时数据区),也就是我们常说的JVM内存。因此,在Java中我们常常说到的内存管理就是针对这段空间进行管理(如何分配和回收内存空间)。
Java内存模型(Java Memory Model,JMM)是Java虚拟机规范定义的,用来屏蔽Java程序在各种不同的硬件和操作系统对内存访问的差异,如此便可实现Java程序在各种不同平台上内存访问的一致性。

根据 JVM 规范,JVM 内存共分为虚拟机栈、堆、方法区、程序计数器、本地方法栈五个部分。

线程私有

程序计数器 占用内存小,生命周期与线程相同
程序计数器是一块较小的内存空间,可以看作是当前线程所执行的字节码行号指示器。分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。
在JVM中,多线程是通过线程轮流切换来获得CPU执行时间的,在任一具体时刻,一个CPU内核只会执行一条线程中的指令。因此,为了线程切换后能恢复到切换前的程序执行位置,每条线程都需要有一个独立的程序计数器,且不能互相被干扰,否则就会影响到程序的正常执行次序,因此,程序计数器可以说是每个线程所私有的。
若线程正在执行一个Java方法,则该计数器记录的是正在执行的虚拟机字节码指令的地址;若线程正在执行的是Native方法,则该计数器值为空(Undefined)。

虚拟机栈 生命周期与线程相同,使用连续的内存空间
Java方法执行的内存模型,存放了一个个栈帧(包括:局部变量表、操作数栈、运行时常量池的引用、方法返回地址和附加信息),每个栈帧对应一个被调用的方法。当线程执行一个方法时,就会随之创建一个对应的栈帧,并将建立的栈帧压栈;方法执行完毕后,便会将栈帧出栈。
:每个线程正在执行的方法可能不同,因此每个线程都会有一个自己的Java栈,互不干扰

本地方法栈 方法区的一部分,具有动态性
与虚拟机栈作用相似,不同之处在于虚拟机栈执行Java方法(字节码)服务;本地方法栈是为虚拟机使用到的Native方法服务。虚拟机栈中对本地方法栈中的方法使用的语言、使用方式与数据结构并没有强制规定,因此具体的虚拟机可以自由实现它。甚至有的虚拟机(如Sun HotSpot虚拟机)直接就把本地方法栈和虚拟机栈合二为一。

线程共享

方法区 生命周期与虚拟机相同,可以不使用连续的内存地址
存储每个类的信息(包括类的名称、方法信息、字段信息)、静态变量、常量以及编译器编译后的代码等。
注:方法区中有一个非常重要的部份——运行时常量池,它是每一个类或接口的常量池的运行时表示形式,在类和接口被加载到JVM后,对应的运行时常量池就被创建出来。
并非Class文件常量池中的内容才能进入运行时常量池,在运行期间也可将新的常量放入运行时常量池中,比如String的Intern方法。

Java堆 生命周期与虚拟机相同,可以不使用连续的内存地址
保存对象实例,所有对象实例(包括数组)都要在堆上分配

  • 对象实例的分配区域
  • GC管理的主要区域

相关面试问题

递归为什么会引发java.lang.StackOverflowError异常?
为什么会引发java.lang.OutOfMemoryError异常
元空间(MetaSpace)与永久代(PermGen)区别
JVM三大性能调优参数
Java内存模型中堆与栈的区别
元空间、堆、栈独占部份间的联系——内存角度
不同JDK版本间intern方法的区别——JDK6 VS JDK6+

补充学习点

CPU与缓存的一致性、并发编程 VS Java内存模型
Java同步操作与规则简述

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值