java 虚拟机(JVM)学习笔记多线程基础知识

java 内存模型-02-数据结构

2018-09-14  本文已影响37人  老马啸西风2020

内存模型

Java内存模型,往往是指Java程序在运行时内存的模型,而Java代码是运行在Java虚拟机之上的,由Java虚拟机通过解释执行(解释器)或编译执行(即时编译器)来完成,故Java内存模型,也就是指Java虚拟机的运行时内存模型。

运行时内存模型

运行时内存模型,分为线程私有和共享数据区两大类,其中线程私有的数据区包含程序计数器、虚拟机栈、本地方法区,所有线程共享的数据区包含Java堆、方法区,在方法区内有一个常量池。

runtime-data

线程私有区:

程序计数器,记录正在执行的虚拟机字节码的地址;

虚拟机栈:方法执行的内存区,每个方法执行时会在虚拟机栈中创建栈帧;

本地方法栈:虚拟机的 native 方法执行的内存区;

线程共享区:

Java堆:对象分配内存的区域;

方法区:存放类信息、常量、静态变量、编译器编译后的代码等数据;

常量池:存放编译器生成的各种字面量和符号引用,是方法区的一部分。

对于大多数的程序员来说,Java内存比较流行的说法便是堆和栈,
这其实是非常粗略的一种划分,这种划分的”堆”对应内存模型的Java堆,”栈”是指虚拟机栈,然而Java内存模型远比这更复杂,想深入了解Java的内存,还是有必要明白整个内存模型。

模型具体介绍

程序计数器

程序计数器可看做当前线程所执行字节码行号的指示器。每个线程都有一个独立的程序计数器,各个线程之间计数器互不影响,独立存储。

如果当前线程执行的是 native 方法,则这个计数器为空。

执行 Java 方法时,这个计数器记录执行字节码指令地址。

虚拟机栈

虚拟机栈,生命周期与线程相同,是Java方法执行的内存模型。

每个方法(不包含 native 方法)执行的同时都会创建一个栈帧结构,方法执行过程,对应着虚拟机栈的入栈到出栈的过程。

栈帧(Stack Frame)结构

栈帧是用于支持虚拟机进行方法执行的数据结构,是属性运行时数据区的虚拟机站的栈元素。见上图, 栈帧包括:

  1. 局部变量表 (locals大小,编译期确定),一组变量存储空间, 容量以slot为最小单位。

  2. 操作栈(stack大小,编译期确定),操作栈元素的数据类型必须与字节码指令序列严格匹配

  3. 动态连接, 指向运行时常量池中该栈帧所属方法的引用,为了 动态连接使用。

  1. 方法返回地址
  1. 额外附加信息,虚拟机规范没有明确规定,由具体虚拟机实现。

异常(Exception)

Java虚拟机规范规定该区域有两种异常:

本地方法栈

本地方法栈则为虚拟机使用到的 native 方法提供内存空间,而前面讲的虚拟机栈式为Java方法提供内存空间。

有些虚拟机的实现直接把本地方法栈和虚拟机栈合二为一,比如非常典型的Sun HotSpot虚拟机。

Java 堆区

Java堆,是Java虚拟机管理的最大的一块内存,也是GC的主战场,里面存放的是几乎所有的对象实例和数组数据。

JIT编译器有栈上分配、标量替换等优化技术的实现导致部分对象实例数据不存在Java堆,而是栈内存。

分配

对象创建的过程是在堆上分配着实例对象,那么对象实例的具体结构如下:

java obejct

对于填充数据不是一定存在的,仅仅是为了字节对齐。

HotSpot VM 的自动内存管理要求对象起始地址必须是 8 字节的整数倍。

对象头本身是8的倍数,当对象的实例数据不是 8 的倍数,便需要填充数据来保证8字节的对齐。该功能类似于高速缓存行的对齐。

另外,关于在堆上内存分配是并发进行的,虚拟机采用CAS加失败重试保证原子操作,或者是采用每个线程预先分配 TLAB 内存。

回收

有些在堆区保存的对象,通过一定的技术手段,自动转变为在栈中完成生命周期,这种技术就是逃逸分析。

把本来存放在堆内存的数据分配到栈中。

这样,数据的生命周期就能随着入栈和出栈而完成管理,不需要像堆内存一样进行内存繁杂的回收操作,减轻堆内存的压力。

Java 堆还可以细分为:

新生代和老年代;

新生代再细致一点有 Eden 空间,From Survivor 空间,To Survivor 空间等。

堆区分代回收是为了区分不同对象的生命周期,并做出合理分配和回收操作。

从分配的角度来看,线程本地缓冲区(Thread local allocation buffer, TLAB)有利于更高效地划分线程私有的缓冲区。

大部分对象生命很短,基本熬不过第一次经历的垃圾收集。

新创建的对象首先存放在 Eden 区。

经过垃圾回收且存活的对象会进入两个 Survivor 中的一个。

此时,这个 Survivor 区就称为 To Survivor。

而另一个区称为 From Survivor。From Survivor 会把本次回收存活的对象移到 To Survivor,然后清空区域内所有对象。

From Survivor 和 To Survivor名字是相对的。

对象移出的区就称为From Survivor,对象进入的区域称为To Survivor。

大多数时间两个区有一个是占用的,另一个已经清空的。

这样,在Survivor的对象会在两个区中来回经历GC,达到一定年龄后会被移到老年代。

因为这个对象多次垃圾回收依然存活,表明这个对象比较稳定,此后在老年代经历垃圾回收的频率非常低。

如果一个新的对象太大,以至于新生代经过一次垃圾回收后依然没有足够空间存放它。JVM会通过分配担保来把这个对象放在老年代。

如果老年代空间不够,经过一次Full GC还是没有空间,那虚拟机无法为这个对象创建内存空间,只能抛出OOM异常停止运行。

方法区

方法区主要存放的是已被虚拟机加载的类信息、常量、静态变量、编译器编译后的代码等数据。

由于永久代存储的数据生命周期非常长,GC在这个区域消耗时间长且回收效果差,所以方法区GC频率很低。

不同回收策略对方法区的处理:

运行时常量池

运行时常量池也是方法区的一部分,用于存放编译器生成的各种字面量和符号引用。

运行时常量池除了编译期产生的Class文件的常量池,还可以在运行期间,将新的常量加入常量池,比较常见的是String类的intern()方法。

  1. 类和接口的全限定名

  2. 字段的名称和描述符

  3. 方法的名称和描述符

但是该区域不会抛出 OutOfMemoryError 异常。

直接内存

直接内存不是虚拟机运行时内存的一部分,该空间划分在虚拟机外。

不过由于直接内存的性能比较好,所以有的工作需要使用直接内存来提高性能。

直接内存会受到物理机剩余可用内存、处理器寻址空间的限制。

可以通过NIO和NIO.2来申请直接内存。如果虚拟机堆内存分配太大,可能会导致直接内存空间不足而出现运行时异常。

变化

元数据区 Metaspace

由于 PermGen 内存管理的效果远没有达到预期,所以JCP已经着手去除PermGen的工作。在JDK7中,字符串常量已经从永久代移除。

现今 JDK8 中 PermGen 已经被彻底移除,取而代之的是metaspace数据区,使用native内存,申请和释放由虚拟机负责管理。

在JDK8下,旧的参数 -XX:PermSize-XX:MaxPermSize 会被忽略并显示警告。

新的Metaspace通过参数 -XX:MetaspaceSize-XX:MaxMetaspaceSize 设定。

G1 内存模型

从JDK7开始引入的G1回收机制,到JDK8时G1已经基本稳定。

G1应该是唯一一个能完成新生代到老年代所有管理的GC技术。

之前的技术如上一节图中CMS和PerNew需要互相配合才能完成完整回收工作。

基于G1回收的特殊性,G1的内存模型和上面介绍的内存模型是有差别的。也就是说,上面的内存模型对G1是无效的。

由于现在还没有太多的资料可以参考G1的内存模型。

如果有需要深入了解,请阅读OpenJDK中HotSpot JVM关于G1的源码。

如果没有特殊要求,现时CMS和PerNew已经很好适应大部分应用场景。毕竟CMS用了多年,出现问题也容易找到解决方法。

主内存和工作内存

Java内存模型的主要目标是定义程序中各个变量的访问规则,即在JVM中将变量存储到内存和从内存中取出变量这样的底层细节。

此处的变量与Java编程里面的变量有所不同步,它包含了实例字段、静态字段和构成数组对象的元素,但不包含局部变量和方法参数,因为后者是线程私有的,不会共享,当然不存在数据竞争问题(如果局部变量是一个reference引用类型,它引用的对象在Java堆中可被各个线程共享,但是reference引用本身在Java栈的局部变量表中,是线程私有的)。

为了获得较高的执行效能,Java内存模型并没有限制执行引起使用处理器的特定寄存器或者缓存来和主内存进行交互,也没有限制即时编译器进行调整代码执行顺序这类优化措施。

JMM规定了所有的变量都存储在主内存(Main Memory)中。

每个线程还有自己的工作内存(Working Memory),线程的工作内存中保存了该线程使用到的变量的主内存的副本拷贝,
线程对变量的所有操作(读取、赋值等)都必须在工作内存中进行,而不能直接读写主内存中的变量(volatile变量仍然有工作内存的拷贝,
但是由于它特殊的操作顺序性规定,所以看起来如同直接在主内存中读写访问一般)。

不同的线程之间也无法直接访问对方工作内存中的变量,线程之间值的传递都需要通过主内存来完成。

实例

x = 0;
线程A:x = 1;
线程B:y = x;

线程A与线程B的通信过程如下:

  1. 首先,线程A把本地内存A中更新过的共享变量刷新到主内存中去

  2. 然后,线程B到主内存中去读取线程A之前已更新过的共享变量

从整体来看,这两个步骤实质上是线程A在向线程B发送消息,而且这个通信过程必须要经过主内存。

JMM(Java Memory Model)通过控制主内存与每个线程的本地内存之间的交互,来为java程序员提供内存可见性保证。

并发特征

Java内存模型是围绕着并发编程中原子性、可见性、有序性这三个特征来建立的。

原子性(Atomicity)

一个操作不能被打断,要么全部执行完毕,要么不执行。在这点上有点类似于事务操作,要么全部执行成功,要么回退到执行该操作之前的状态。

基本类型数据的访问大都是原子操作,long 和 double 类型的变量是64位,但是在32位JVM中,32位的JVM会将64位数据的读写操作分为2次32位的读写操作来进行,这就导致了long、double 类型的变量在32位虚拟机中是非原子操作,数据有可能会被破坏,也就意味着多个线程在并发访问的时候是线程非安全的。

下面我们来演示这个32位JVM下,对64位long类型的数据的访问的问题:

public class NotAtomicity {
    //静态变量t
    public  static long t = 0;
    //静态变量t的get方法
    public  static long getT() {
        return t;
    }
    //静态变量t的set方法
    public  static void setT(long t) {
        NotAtomicity.t = t;
    }
    //改变变量t的线程
    public static class ChangeT implements Runnable{
        private long to;
        public ChangeT(long to) {
            this.to = to;
        }
        public void run() {
            //不断的将long变量设值到 t中
            while (true) {
                NotAtomicity.setT(to);
                //将当前线程的执行时间片段让出去,以便由线程调度机制重新决定哪个线程可以执行
                Thread.yield();
            }
        }
    }
    //读取变量t的线程,若读取的值和设置的值不一致,说明变量t的数据被破坏了,即线程不安全
    public static class ReadT implements Runnable{

        public void run() {
            //不断的读取NotAtomicity的t的值
            while (true) {
                long tmp = NotAtomicity.getT();
                //比较是否是自己设值的其中一个
                if (tmp != 100L && tmp != 200L && tmp != -300L && tmp != -400L) {
                    //程序若执行到这里,说明long类型变量t,其数据已经被破坏了
                    System.out.println(tmp);
                }
                ////将当前线程的执行时间片段让出去,以便由线程调度机制重新决定哪个线程可以执行
                Thread.yield();
            }
        }
    }
    public static void main(String[] args) {
        new Thread(new ChangeT(100L)).start();
        new Thread(new ChangeT(200L)).start();
        new Thread(new ChangeT(-300L)).start();
        new Thread(new ChangeT(-400L)).start();
        new Thread(new ReadT()).start();
    }
}

我们创建了4个线程来对long类型的变量t进行赋值,赋值分别为100,200,-300,-400,有一个线程负责读取变量t,如果正常的话,读取到的t的值应该是我们赋值中的一个,但是在32的JVM中,事情会出乎预料。如果程序正常的话,我们控制台不会有任何的输出,可实际上,程序一运行,控制台就输出了下面的信息:

-4294967096
4294966896
-4294967096
-4294967096
4294966896

之所以会出现上面的情况,是因为在32位JVM中,64位的long数据的读和写都不是原子操作,即不具有原子性,并发的时候相互干扰了。

可见性

一个线程对共享变量做了修改之后,其他的线程立即能够看到(感知到)该变量这种修改(变化)。

Java内存模型是通过将在工作内存中的变量修改后的值同步到主内存,在读取变量前从主内存刷新最新值到工作内存中,这种依赖主内存的方式来实现可见性的。

无论是普通变量还是volatile变量都是如此,

区别在于:volatile 的特殊规则保证了 volatile 变量值修改后的新值立刻同步到主内存,
每次使用 volatile 变量前立即从主内存中刷新,因此 volatile 保证了多线程之间的操作变量的可见性,而普通变量则不能保证这一点。

除了 volatile 关键字能实现可见性之外,还有 synchronized, Lockfinal 也是可以的。

使用 synchronized 关键字,在同步方法/同步块开始时(Monitor Enter),
使用共享变量时会从主内存中刷新变量值到工作内存中(即从主内存中读取最新值到线程私有的工作内存中),
在同步方法/同步块结束时(Monitor Exit),会将工作内存中的变量值同步到主内存中去(即将线程私有的工作内存中的值写入到主内存进行同步)。

使用Lock接口的最常用的实现ReentrantLock(重入锁)来实现可见性:
当我们在方法的开始位置执行 lock.lock() 方法,这和 synchronized 开始位置(Monitor Enter)有相同的语义,
即使用共享变量时会从主内存中刷新变量值到工作内存中(即从主内存中读取最新值到线程私有的工作内存中),
在方法的最后 finally 块里执行 lock.unlock() 方法,
和 synchronized 结束位置(Monitor Exit)有相同的语义,即会将工作内存中的变量值同步到主内存中去(即将线程私有的工作内存中的值写入到主内存进行同步)。

final 关键字的可见性是指:

被final修饰的变量,在构造函数数一旦初始化完成,
并且在构造函数中并没有把“this”的引用传递出去(“this”引用逃逸是很危险的,其他的线程很可能通过该引用访问到只“初始化一半”的对象),
那么其他线程就可以看到final变量的值。

有序性

对于一个线程的代码而言,我们总是以为代码的执行是从前往后的,依次执行的。

这么说不能说完全不对,在单线程程序里,确实会这样执行;但是在多线程并发时,程序的执行就有可能出现乱序。

用一句话可以总结为:在本线程内观察,操作都是有序的;如果在一个线程中观察另外一个线程,所有的操作都是无序的。前半句是指“线程内表现为串行语义(WithIn Thread As-if-Serial Semantics)”,后半句是指“指令重排”现象和“工作内存和主内存同步延迟”现象。

Java提供了两个关键字 volatilesynchronized 来保证多线程之间操作的有序性,
volatile 关键字本身通过加入内存屏障来禁止指令的重排序
synchronized关键字通过一个变量在同一时间只允许有一个线程对其进行加锁的规则来实现,

在单线程程序中,不会发生“指令重排”和“工作内存和主内存同步延迟”现象,只在多线程程序中出现。

参考资料

http://gityuan.com/2016/01/09/java-memory/

https://www.jianshu.com/p/1579aafac60b

https://www.cnblogs.com/lewis0077/p/5143268.html

https://blog.csdn.net/suifeng3051/article/details/52611310

原文地址

jmm-02-data-struct

上一篇 下一篇

猜你喜欢

热点阅读