一篇文章读懂JAVA内存管理

1. JVM的概念

JVM是Java Virtual Machine(Java虚拟机)的缩写,JVM是一种用于计算设备的规范,它是一个虚构出来的计算机,是通过在实际的计算机上仿真模拟各种计算机功能来实现的。

Java语言的一个非常重要的特点就是与平台的无关性。而使用Java虚拟机是实现这一特点的关键。一般的高级语言如果要在不同的平台上运行,至少需要编译成不同的目标代码。而引入Java语言虚拟机后,Java语言在不同平台上运行时不需要重新编译。Java语言使用Java虚拟机屏蔽了与具体平台相关的信息,使得Java语言编译程序只需生成在Java虚拟机上运行的目标代码(字节码),就可以在多种平台上不加修改地运行。Java虚拟机在执行字节码时,把字节码解释成具体平台上的机器指令执行。这就是Java的能够“一次编译,到处运行”的原因。

2. JMM的概念

JMM是Java Memory Model(Java内存模型)的缩写。JMM定义了Java 虚拟机(JVM)在计算机内存(RAM)中的工作方式。JVM是整个计算机虚拟模型,所以JMM是隶属于JVM的。Java线程之间的通信由JMM控制,JMM决定一个线程对共享变量的写入何时对另一个线程可见。

如果我们要想深入了解Java并发编程,就要先理解好Java内存模型。Java内存模型定义了多线程之间共享变量的可见性以及如何在需要的时候对共享变量进行同步。

3. 并发编程模型的分类

在并发编程中,我们需要处理两个关键问题:线程之间如何通信及线程之间如何同步。

  • 线程通信: 是指线程之间以何种机制来交换信息
  • 线程同步: 是指程序用于控制不同线程之间操作发生相对顺序的机制

在命令式编程中,线程之间的通信机制有两种:

  • 共享内存模型
  • 消息传递模型

3.1 共享内存模型

  • 线程通信 在共享内存的并发模型里,线程之间共享程序的公共状态,线程之间通过写-读内存中的公共状态来隐式进行通信,典型的共享内存通信方式就是通过共享对象进行通信。

  • 线程同步 在共享内存的并发模型里,同步是显式进行的。程序员必须显式指定某个方法或某段代码需要在线程之间互斥执行。

3.2 消息传递模型

  • 线程通信 在消息传递的并发模型里,线程之间没有公共状态,线程之间必须通过明确的发送消息来显式进行通信,在java中典型的消息传递方式就是wait()和notify()。

  • 线程同步 在消息传递的并发模型里,由于消息的发送必须在消息的接收之前,因此同步是隐式进行的。

4. Java的并发采用的是共享内存模型

Java线程之间的通信总是隐式进行,整个通信过程对程序员完全透明。如果编写多线程程序的Java程序员不理解隐式进行的线程之间通信的工作机制,很可能会遇到各种奇怪的内存可见性问题。

5. Java内存模型

Java内存模型(简称JMM)定义了线程和主内存之间的抽象关系:

  • 主内存 根据JMM的设计,系统存在一个主内存(Main Memory),Java中所有共享变量都储存在主内存中,对于所有线程都是共享的。

  • 本地内存 每条线程都有自己的本地内存(Local Memory),本地内存中存储了该线程以读/写共享变量的副本。线程对所有变量的操作都是在本地内存中进行,线程之间无法相互直接访问,变量传递均需要通过主内存完成。
    本地内存是JMM的一个抽象概念,并不真实存在。它涵盖了缓存,写缓冲区,寄存器以及其他的硬件和编译器优化。

Java内存模型

从上图来看,线程A与线程B之间如要通信的话,必须要经历下面2个步骤:

1. 首先,线程A把本地内存A中更新过的共享变量刷新到主内存中去。
2. 然后,线程B到主内存中去读取线程A之前已更新过的共享变量。

从整体来看,这两个步骤实质上是线程A在向线程B发送消息,而且这个通信过程必须要经过主内存。JMM通过控制主内存与每个线程的本地内存之间的交互,来为Java程序员提供内存可见性保证。

上面也说到了,Java内存模型只是一个抽象概念,那么它在Java中具体是怎么工作的呢?为了更好的理解Java内存模型工作方式,下面就JVM对Java内存模型的实现、硬件内存模型及它们之间的桥接做详细介绍。

6. Java内存模型在JVM中的实现

关于JVM对Java内存模型的实现,我们从JVM的逻辑视图,硬件架构,CUP指令等多个角度进行解读。

6.1 JVM逻辑视图

在JVM内部,Java内存模型把内存分成了两部分:线程栈区和堆区,下图展示了Java内存模型在JVM中的逻辑视图:


Java内存模型在JVM中的逻辑视图
  • Thread Stack JVM中运行的每个线程都拥有自己的线程栈,线程栈包含了当前线程执行的方法调用相关信息,我们也把它称作调用栈。随着代码的不断执行,调用栈会不断变化。
    线程栈还包含了当前方法的所有本地变量信息。所有原始类型(boolean,byte,short,char,int,long,float,double)的本地变量都直接保存在线程栈当中,对于它们的值各个线程之间都是独立的。也就是说,一个线程只能读取自己的线程栈,线程中的本地变量对其它线程是不可见的。即使两个线程执行的是同一段代码,它们也会各自在自己的线程栈中创建本地变量。

  • Heap 堆区包含了Java应用创建的所有对象信息,不管对象是哪个线程创建的,其中的对象包括原始类型的封装类(如Byte、Integer、Long等等)。
    堆中的对象可以被多线程共享。如果一个线程获得一个对象的引用,它便可访问这个对象的成员变量。如果两个线程同时调用了同一个对象的同一个方法,那么这两个线程便可同时访问这个对象的成员变量,但是对于本地变量,每个线程都会拷贝一份到自己的线程栈中。

下图展示了上面描述的过程:


Java内存模型示例

关于栈区和堆区的存储内容,请参考下面的原则:

  1. 一个本地变量如果是原始类型,那么它会被完全存储到栈区
  2. 一个本地变量也有可能是一个对象的引用,这种情况下,这个本地引用会被存储到栈中,但是对象本身仍然存储在堆区
  3. 对于一个对象的成员方法,这些方法中包含本地变量,仍需要存储在栈区,即使它们所属的对象在堆区
  4. 对于一个对象的成员变量,不管它是原始类型还是包装类型,都会被存储到堆区
  5. Static类型的变量以及类本身相关信息都会随着类本身存储在堆区

JVM参数对于内存的设置

  • 堆内存:

    • -Xms 初始分配堆内存 默认是物理内存的1/64
    • -Xmx 最大堆内存 默认是物理内存的1/4
    • 【增长策略】
      默认空余堆内存小于40%时,JVM就会增大堆直到-Xmx的最大限制
      空余堆内存大于70%时,JVM会减少堆直到-Xms的最小限制
      因此服务器一般设置-Xms、-Xmx 相等以避免在每次GC 后调整堆的大小。
    • 【注意事项】
      如果-Xmx 不指定或者指定偏小,应用可能会导致java.lang.OutOfMemory错误,此错误来自JVM,不是Throwable的,无法用try…catch捕捉。
  • 非堆 Non-heap 内存

    • -XX:PermSize 设置非堆内存初始值,其缩写为permanent size(持久化内存),默认是物理内存的1/64
    • -XX:MaxPermSize 设置最大非堆内存的大小,默认是物理内存的1/4
    • 【注意事项】
      非堆内存不会被java垃圾回收机制进行处理

6.2 计算机硬件内存架构

不管是什么内存模型,最终还是运行在计算机硬件上的,所以我们有必要了解计算机硬件内存架构,下图就简单描述了当代计算机硬件内存架构:


硬件内存架构
  • CUP 现代计算机一般都有2个以上CPU,而且每个CPU还有可能包含多个核心。因此,如果我们的应用是多线程的话,这些线程可能会在各个CPU核心中并行运行。

  • CUP Registers 在CPU内部有一组CPU寄存器,也就是CPU的储存器。CPU操作寄存器的速度要比操作计算机主存快的多。

  • CUP Cache Memory 在主存和CPU寄存器之间还存在一个CPU缓存,CPU操作CPU缓存的速度快于主存但慢于CPU寄存器。某些CPU可能有多个缓存层(一级缓存和二级缓存)。

  • RAM - Main Memory 计算机的主存也称作RAM,所有的CPU都能够访问主存,而且主存比上面提到的缓存和寄存器大很多。

当一个CPU需要访问主存时,会先读取一部分主存数据到CPU缓存,进而在读取CPU缓存到寄存器。

当一个CPU需要写数据到主存时,同样会先flush寄存器到CPU缓存,然后再在某些节点把缓存数据flush到主存。

6.3 Java内存模型和硬件架构之间的桥接

从上面可以看出,Java内存模型和硬件内存架构并不一致。硬件内存架构中并没有区分栈和堆,从硬件上看,不管是栈还是堆,大部分数据都会存到主存中,当然一部分栈和堆的数据也有可能会存到CPU寄存器中,如下图所示,Java内存模型和计算机硬件内存架构是一个交叉关系:


Java内存模型和计算机硬件内存架构的关系

6.4 多线程产生的内存问题

在我们进行多线程编程时,对象和变量存储到计算机的各个内存区域的,这样必然会面临一些问题,其中最主要的两个问题是:

1. 共享对象对各个线程的可见性
2. 共享对象的竞争现象

下面详细我们说一下这两个问题,并了解一下Java的是怎么解决这些问题的

6.4.1 共享对象的可见性

假设我们的共享对象存储在主存,一个CPU中的线程读取主存数据到CPU缓存,然后对共享对象做了更改,但CPU缓存中的更改后的对象还没有flush到主存,此时这个线程对共享对象的更改对其它CPU中的线程是不可见的。最终就是每个线程最终都会拷贝共享对象,而且拷贝的对象位于不同的CPU缓存中。

下图展示了上面描述的过程。左边CPU中运行的线程从主存中拷贝共享对象obj到它的CPU缓存,把对象obj的count变量改为2。但这个变更对运行在右边CPU中的线程不可见,因为这个更改还没有flush到主存中:


共享对象的可见性

要解决共享对象可见性这个问题,我们可以使用Java的volatile关键字。 volatile 关键字可以保证变量会直接从主存读取,而对变量的更新也会直接写到主存。volatile原理 是基于CPU内存屏障指令实现的,后面会讲到。

6.4.2 竞争现象

如果多个线程共享一个对象,如果它们要同时修改这个共享对象,这就产生了竞争现象。

如下图所示,线程A和线程B共享一个对象obj。假设线程A从主存读取Obj.count变量到自己的CPU缓存,同时,线程B也读取了Obj.count变量到它的CPU缓存,并且这两个线程都对Obj.count做了加1操作。此时,Obj.count加1操作被执行了两次,不过都在不同的CPU缓存中。

如果这两个加1操作是串行执行的,那么Obj.count变量便会在原始值上加2,最终主存中的Obj.count的值会是3。然而下图中两个加1操作是并行的,不管是线程A还是线程B先flush计算结果到主存,最终主存中的Obj.count只会增加1次变成2,尽管一共有两次加1操作。


20160921183251870.jpg

要解决上面的问题我们可以使用synchronized代码块。synchronized代码块可以保证同一个时刻只能有一个线程进入代码竞争区,synchronized代码块也能保证代码块中所有变量都将会从主存中读,当线程退出代码块时,对所有变量的更新将会flush到主存,不管这些变量是不是volatile类型的。

volatile和synchronized的区别
  1. volatile本质是在告诉JVM当前变量在寄存器(本地内存)中的值是不确定的,需要从主存中读取; synchronized则是锁定当前变量,只有当前线程可以访问该变量,其他线程被阻塞住。
  2. volatile仅能使用在变量级别;synchronized则可以使用在变量、方法、和类级别的
  3. volatile仅能实现变量的修改可见性,不能保证原子性;而synchronized则可以保证变量的修改可见性和原子性
  4. volatile不会造成线程的阻塞;synchronized可能会造成线程的阻塞。
  5. volatile标记的变量不会被编译器优化;synchronized标记的变量可以被编译器优化

6.5 JMM深层解析

6.5.1 重排序

重排序通常是编译器或运行时环境为了优化程序性能而采取的对指令进行重新排序执行的一种手段。但是,重排序可能会导致程序执行的结果不是我们需要的结果。重排序分三种类型:

  1. 编译器优化的重排序 编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。
  2. 指令级并行的重排序 现代处理器采用了指令级并行技术(Instruction-Level Parallelism, ILP)来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。
  3. 内存系统的重排序 由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。

从Java源代码到最终实际执行的指令序列,会分别经历下面三种重排序:


重排序

上述的1属于编译器重排序,2和3属于处理器重排序。这些重排序都可能会导致多线程程序出现内存可见性问题。

6.5.2 Memory Barrier 内存屏障指令

内存屏障,又称内存栅栏,是一个CPU指令,基本上它是一条这样的指令:

1、保证特定操作的执行顺序。
2、影响某些数据(或则是某条指令的执行结果)的内存可见性。

编译器和处理器能够重排序指令,保证最终相同的结果,尝试优化性能。但插入一条Memory Barrier会告诉编译器和处理器:不管什么指令都不能和这条Memory Barrier指令重排序。

Memory Barrier所做的另外一件事是强制刷出各种CPU cache,如一个 Write-Barrier(写入屏障)将刷出所有在 Barrier 之前写入 cache 的数据,因此,任何CPU上的线程都能读取到这些数据的最新版本。

volatile

volatile是基于Memory Barrier实现的。

如果一个变量是volatile修饰的,JMM会在写入这个字段之后插进一个Write-Barrier指令,并在读这个字段之前插入一个Read-Barrier指令。

这意味着,如果写入一个volatile变量a,可以保证:

  1. 一个线程写入变量a后,任何线程访问该变量都会拿到最新值。
  2. 在写入变量a之前的写入操作,其更新的数据对于其他线程也是可见的。因为Memory Barrier会刷出cache中的所有先前的写入。
6.5.3 happens-before

从JDK5开始,Java使用新的JSR -133内存模型。JSR-133使用happens-before的概念来指定两个操作之间的执行顺序。由于这两个操作可以在一个线程之内,也可以是在不同线程之间。因此,JMM可以通过happens-before关系向程序员提供跨线程的内存可见性保证

我们通过代码示例来了解一下什么是 happens-before

double pi = 3.14;   // A
double r = 1.0;     // B
double area = pi * r * r;  // C

上述计算圆的面积的代码中,存在三个happens-before关系:

  1. A happens-before B
  2. B happens-before C
  3. A happens-before C

在者三个happens-before关系中2和3是必须的,1是不必要的。因此JMM把happens-before要求禁止的重排序分了下面两类:

  1. 会改变程序执行结果的重排序
  2. 不会改变程序执行结果的重排序

JMM对这两种不同性质的重排序,采用了不同的策略,如下:

  1. 对于会改变程序执行结果的重排序,JMM要求编译器和处理器必须禁止这种重排序
  2. 对于不会改变程序执行结果的重排序,JMM对编译器和处理器不做要求,也就是说JMM允许这种重排序

下图是JMM的设计示意图


happens-before

JMM对编译器和处理器的束缚已经尽可能少。从上面的分析可以看出,JMM其实是在遵循一个基本原则:只要不改变程序的执行结果(指的是单线程程序和正确同步的多线程程序),编译器和处理器怎么优化都行。例如,如果编译器经过细致的分析后,认定一个锁只会被单个线程访问,那么这个锁可以被消除。再如,如果编译器经过细致的分析后,认定一个volatile变量只会被单个线程访问,那么编译器可以把这个volatile变量当作一个普通变量来对待。这些优化既不会改变程序的执行结果,又能提高程序的执行效率。

happens-before规则
  • 程序顺序规则:一个线程中的每个操作,happens-before 于该线程中的任意后续操作
  • 监视器锁规则:对一个监视器锁的解锁,happens-before 于随后对这个监视器锁的加锁
  • volatile变量规则:对一个volatile域的写,happens-before 于任意后续对这个volatile域的读
  • 传递性:如果A happens- before B,且B happens-before C,那么A happens-before C
  • start规则:如果线程A执行操作ThreadB.start()(启动线程B),那么A线程的ThreadB.start()操作happens-before于线程B中的任意操作
  • join规则:如果线程A执行操作ThreadB.join()并成功返回,那么线程B中的任意操作happens-before于线程A从ThreadB.join()操作成功返回。

对于Java程序员来说,happens-before规则简单易懂,它避免程序员为了理解JMM提供的内存可见性保证而去学习复杂的重排序规则以及这些规则的具体实现。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 199,830评论 5 468
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 83,992评论 2 376
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 146,875评论 0 331
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 53,837评论 1 271
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 62,734评论 5 360
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,091评论 1 277
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,550评论 3 390
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,217评论 0 254
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,368评论 1 294
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,298评论 2 317
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,350评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,027评论 3 315
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,623评论 3 303
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,706评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,940评论 1 255
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,349评论 2 346
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 41,936评论 2 341

推荐阅读更多精彩内容

  • Java的并发采用的是共享内存模型(而非消息传递模型),线程之间共享程序的公共状态,线程之间通过写-读内存中的公共...
    阿斯蒂芬2阅读 464评论 0 1
  • 第2章 java并发机制的底层实现原理 Java中所使用的并发机制依赖于JVM的实现和CPU的指令。 2.1 vo...
    kennethan阅读 1,384评论 0 2
  • 目录: 1. 指令重排 2. 顺序一致性 3. volatile 4. final 1.指令重排 要了解指令重排,...
    西部小笼包阅读 735评论 0 1
  • 一个年青人拿着一束鲜花,坐在广场雕像的台阶上,看样子在等人 。他从早上一直坐到傍晚,直到天完全黑下来,他才离去。 ...
    五妹的欲望街车阅读 541评论 8 10
  • 我从今年元旦开始加入了写作群,也每天都按时提交了作业,初衷是为了逼迫自己每天都要写点什么,养成良好的习惯,可是最近...
    晴空霁月阅读 193评论 2 0