java虚拟机_02_Jvm内存模型

一、内存介绍

1.1 计算机内存

我们知道

  • 计算机CPU和内存的交互是最频繁的
  • 内存是我们的高速缓存区,用户磁盘和CPU的交互,而CPU运转速度越来越快,磁盘远远跟不上CPU的读写速度,才设计了内存,用户缓冲用户IO等待导致CPU的等待成本,但是随着CPU的发展,内存的读写速度也远远跟不上CPU的读写速度

因此,为了解决这一纠纷,CPU厂商在每颗CPU上加入了高速缓存,用来缓解这种症状,因此,现在CPU同内存交互就变成了下面的样子。
《java虚拟机_02_Jvm内存模型》

  • 同样,根据摩尔定律,我们知道单核CPU的主频不可能无限制的增长,要想很多的提升新能,需要多个处理器协同工作, Intel总裁的贝瑞特单膝下跪事件标志着多核时代的到来。

    基于高速缓存的存储交互很好的解决了处理器与内存之间的矛盾,也引入了新的问题:缓存一致性问题。在多处理器系统中,每个处理器有自己的高速缓存,而他们又共享同一块内存(下文成主存,main memory 主要内存),当多个处理器运算都涉及到同一块内存区域的时候,就有可能发生缓存不一致的现象。为了解决这一问题,需要各个处理器运行时都遵循一些协议,在运行时需要将这些协议保证数据的一致性。

    这类协议包括MSI、MESI、MOSI、Synapse、Firely、DragonProtocol等。如下图所示
    《java虚拟机_02_Jvm内存模型》

    1.2 Java虚拟机内存

    • Java虚拟机内存模型中定义的访问操作与物理计算机处理的基本一致!

    《java虚拟机_02_Jvm内存模型》

JVM内存区域
先看一张图,这张图能很清晰的说明JVM内存结构布局。
《java虚拟机_02_Jvm内存模型》

二 JVM内存区域介绍

从更高的一个维度再次来看JVM和系统调用之间的关系
《java虚拟机_02_Jvm内存模型》

线程共享内存

  • 可以被所有线程共享的区域,包括堆区、方法区、运行时常量池。

2.1堆(Heap)

大多数时候,Java 堆是 Java 虚拟机管理的内存里最大的一块,所有的对象实例和数组都要在堆上分配内存空间,Java 对象可以分为两类,一类是快速创建快速消亡的,另一类是长期使用的。所以针对这种情况大多收集器都是基于分代收集算法进行回收。

Java 的堆可以分为新生代(Young Generation)和老年代(Old Generation),而新生代(Young Generation)又可以分为 Eden Space 空间 (伊甸园区)、From Survivor 空间(From 生存区)、To Survivor 空间(To 生存区)。

Java 堆是一块共享的区域,会出现线程安全的问题,而操作共享区域就需要锁和同步,通过- Xms设置堆的最小值,堆内存越小越容易发生内存不够用的情况而触犯 Full GC(对新生代、老年代、永久代进行垃圾回收)。官方推荐新生代大小占整个堆大小的 3/8,通过- Xmx设置堆的最大值,堆内存超过此值会发抛出 OutOfMemoryError 异常:

2.2 方法区(Method Area)

方法区(Method Area)在 HotSpot 虚拟机上可以看作是永久代(Permanent Generation),对于其他虚拟机(JRockit 、J9 等)来说是不存在永久代的。方法区也是所有线程共享的区域,主要存储被虚拟机加载的类信息、常量、静态变量,堆存储对象数据,方法区存储静态信息。

方法区不像 Java 堆区那样经常发生垃圾回收,但不表示不会发生。永久代的大小跟新生代、老年代比都是很小的,通过设置- XX:MaxPermSize来指定最大内存,方法区需要的内存超过此值会抛出 OutOfMemoryError 异常。

2.3 运行时常量池(Runtime Constant Pool)

Java 通过类加载机制可以把字节码文件中的常量池表加载进运行时常量池,而我们也可使用 String 类的 intern() 方法在运行期将新的常量放入池中,运行时常量池是方法区的一部分,在 JDK1.7 的 HotSpot 中,已经把原本放在方法区的常量池移出来了。

线程私有内存

  • 只允许被所属的线程私自访问的内存区,包括 PC 寄存器、Java 栈和本地方法栈。

2.4 栈(Java Stack)

Java Stack 描述的是 Java 方法执行时的内存模型,每个方法执行时都会创建一个栈帧(Stack Frame),栈帧包含局部变量表(存放编译期间的各种基本数据类型,对象引用等信息)、操作数栈、动态链接、方法出口等数据。

一个线程运行时会分配栈空间,每个线程的栈空间数据是相互隔离的,所以栈是私有的,堆是共享的,一个线程执行多个方法,会入栈出栈多个栈帧(多个方法),栈是先进后出的数据结构,最先入栈的栈帧,最后出栈,可以通过-Xss设置每个线程栈的大小,越小,能创建的线程数就越多,但并不是可以无限的,在一个进程里(JVM 进程)能生成的线程数最多不超过五千

2.5 本地方法栈(Native Stack)

虚拟机栈(Java Stack)为执行 Java 方法(就是字节码)服务,而本地方法栈(Native Stack)则为 Native 方法(比如用 C/C++ 编写的代码)服务,其他方面都很类似。

2.6 PC 寄存器(程序计数器)

JVM 字节码解析器通过改变 PC 寄存器的值来明确下一条需要执行的字节码指令,每个线程都会分配一个独立的 PC 寄存器。

内存可见性-volatile

  • 每一个线程有一个工作内存和主存独立
  • 工作内存存放主存中变量的值的拷贝

《java虚拟机_02_Jvm内存模型》
《java虚拟机_02_Jvm内存模型》
《java虚拟机_02_Jvm内存模型》

2.7 volatile

  • volatile是java提供的一种同步手段,只不过它是轻量级的同步
    为什么这么说,因为volatile只能保证多线程的内存可见性,不能保证多线程的执行有序性。而最彻底的同步要保证有序性和可见性,例如synchronized。任何被volatile修饰的变量,都不拷贝副本到工作内存,任何修改都及时写在主存。因此对于Valatile修饰的变量的修改,所有线程马上就能看到,但是volatile不能保证对变量的修改是有序的

可见性

  • 一个线程修改了变量,其他线程可以立即知道

保证可见性的方法

  • volatile
  • synchronized (unlock之前,写变量值回主存)
  • final(一旦初始化完成,其他线程就可见)

2.7.1 Volatile 变量

  • Volatile 变量具有 synchronized 的可见性特性,但是不具备原子特性。
    这就是说线程能够自动发现 volatile 变量的最新值。Volatile 变量可用于提供线程安全,但是只能应用于非常有限的一组用例:多个变量之间或者某个变量的当前值与修改后值之间没有约束。因此,单独使用 volatile 还不足以实现计数器、互斥锁或任何具有与多个变量相关的不变式(Invariants)的类(例如 “start <=end”)。

  • 出于简易性或可伸缩性的考虑,您可能倾向于使用 volatile 变量而不是锁。
    当使用 volatile 变量而非锁时,某些习惯用法(idiom)更加易于编码和阅读。此外,volatile 变量不会像锁那样造成线程阻塞,因此也很少造成可伸缩性问题。在某些情况下,如果读操作远远大于写操作,volatile 变量还可以提供优于锁的性能优势。

2.7.2 正确使用 volatile 变量的条件

  • Volatile变量修饰符如果使用恰当的话,它比synchronized的使用和执行成本会更低,因为它不会引起线程上下文的切换和调度

  • 您只能在有限的一些情形下使用 volatile 变量替代锁。
    要使 volatile 变量提供理想的线程安全,必须同时满足下面两个条件:

    • 对变量的写操作不依赖于当前值。
    • 该变量没有包含在具有其他变量的不变式中。

实际上,这些条件表明,可以被写入 volatile 变量的这些有效值独立于任何程序的状态,包括变量的当前状态。

第一个条件的限制使 volatile 变量不能用作线程安全计数器。虽然增量操作(x++)看上去类似一个单独操作,实际上它是一个由读取-修改-写入操作序列组成的组合操作,必须以原子方式执行,而 volatile 不能提供必须的原子特性。实现正确的操作需要使 x 的值在操作期间保持不变,而 volatile 变量无法实现这点。(然而,如果将值调整为只从单个线程写入,那么可以忽略第一个条件。)

2.7.2 volatile性能考虑

使用 volatile 变量的主要原因是其简易性:在某些情形下,使用 volatile 变量要比使用相应的锁简单得多。使用 volatile 变量次要原因是其性能:某些情况下,volatile 变量同步机制的性能要优于锁。

很难做出准确、全面的评价,例如 “X 总是比 Y 快”,尤其是对 JVM 内在的操作而言。(例如,某些情况下 VM 也许能够完全删除锁机制,这使得我们难以抽象地比较 volatile 和 synchronized 的开销。)就是说,在目前大多数的处理器架构上,volatile 读操作开销非常低 —— 几乎和非 volatile 读操作一样。而 volatile 写操作的开销要比非 volatile 写操作多很多,因为要保证可见性需要实现内存界定(Memory Fence),即便如此,volatile 的总开销仍然要比锁获取低。

volatile 操作不会像锁一样造成阻塞,因此,在能够安全使用 volatile 的情况下,volatile 可以提供一些优于锁的可伸缩特性。如果读操作的次数要远远超过写操作,与锁相比,volatile 变量通常能够减少同步的性能开销。

2.7.3 volatile的适用场景

模式 #1:状态标志

也许实现 volatile 变量的规范使用仅仅是使用一个布尔状态标志,用于指示发生了一个重要的一次性事件,例如完成初始化或请求停机。
低效:

volatile boolean shutdownRequested;

...

public void shutdown() { 
    shutdownRequested = true; 
}

public void doWork() { 
    while (!shutdownRequested) { 
        // do stuff
    }

线程1执行doWork()的过程中,可能有另外的线程2调用了shutdown,所以boolean变量必须是volatile。

而如果使用 synchronized 块编写循环要比使用 volatile 状态标志编写麻烦很多。由于 volatile 简化了编码,并且状态标志并不依赖于程序内任何其他状态,因此此处非常适合使用 volatile。

这种类型的状态标记的一个公共特性是:通常只有一种状态转换;shutdownRequested 标志从false 转换为true,然后程序停止。这种模式可以扩展到来回转换的状态标志,但是只有在转换周期不被察觉的情况下才能扩展(从false 到true,再转换到false)。此外,还需要某些原子状态转换机制,例如原子变量。

模式 #2:一次性安全发布(one-time safe publication)

缺乏同步的情况下,可能会遇到某个对象引用的更新值(由另一个线程写入)和该对象状态的旧值同时存在。

这就是造成著名的双重检查锁定(double-checked-locking)问题的根源,其中对象引用在没有同步的情况下进行读操作,产生的问题是您可能会看到一个更新的引用,但是仍然会通过该引用看到不完全构造的对象。

//注意volatile!!!!!!!!!!!!!!!!! 
private volatile static Singleton instace;   
public static Singleton getInstance(){   
    //第一次null检查 
    if(instance == null){            
        synchronized(Singleton.class) {    //1 
            //第二次null检查 
            if(instance == null){          //2 
                instance = new Singleton();//3 
            }  
        }           
    }  
    return instance;     

如果不用volatile,则因为内存模型允许所谓的“无序写入”,可能导致失败。——某个线程可能会获得一个未完全初始化的实例。

考察上述代码中的 //3 行。此行代码创建了一个 Singleton 对象并初始化变量 instance 来引用此对象。这行代码的问题是:在Singleton 构造函数体执行之前,变量instance 可能成为非 null 的!
什么?这一说法可能让您始料未及,但事实确实如此。

在解释这个现象如何发生前,请先暂时接受这一事实,我们先来考察一下双重检查锁定是如何被破坏的。假设上述代码执行以下事件序列:

  • 线程 1 进入 getInstance() 方法。
  • 由于 instance 为 null,线程 1 在 //1 处进入synchronized 块。
  • 线程 1 前进到 //3 处,但在构造函数执行之前,使实例成为非null。
  • 线程 1 被线程 2 预占。
  • 线程 2 检查实例是否为 null。因为实例不为 null,线程 2 将instance 引用返回,返回一个构造完整但部分初始化了的Singleton 对象。
  • 线程 2 被线程 1 预占。
  • 线程 1 通过运行 Singleton 对象的构造函数并将引用返回给它,来完成对该对象的初始化。
模式 #3:独立观察(independent observation)

安全使用 volatile 的另一种简单模式是:定期 “发布” 观察结果供程序内部使用。【例如】假设有一种环境传感器能够感觉环境温度。一个后台线程可能会每隔几秒读取一次该传感器,并更新包含当前文档的 volatile 变量。然后,其他线程可以读取这个变量,从而随时能够看到最新的温度值。

使用该模式的另一种应用程序就是收集程序的统计信息。【例】如下代码展示了身份验证机制如何记忆最近一次登录的用户的名字。将反复使用lastUser 引用来发布值,以供程序的其他部分使用。

public class UserManager {
    public volatile String lastUser; //发布的信息

    public boolean authenticate(String user, String password) {
        boolean valid = passwordIsValid(user, password);
        if (valid) {
            User u = new User();
            activeUsers.add(u);
            lastUser = user;
        }
        return valid;
    }
}
模式 #4:“volatile bean” 模式

volatile bean 模式的基本原理是:很多框架为易变数据的持有者(例如 HttpSession)提供了容器,但是放入这些容器中的对象必须是线程安全的。

在 volatile bean 模式中,JavaBean 的所有数据成员都是 volatile 类型的,并且 getter 和 setter 方法必须非常普通——即不包含约束!


@ThreadSafe
public class Person {
    private volatile String firstName;
    private volatile String lastName;
    private volatile int age;

    public String getFirstName() { return firstName; }
    public String getLastName() { return lastName; }
    public int getAge() { return age; }

    public void setFirstName(String firstName) { 
        this.firstName = firstName;
    }

    public void setLastName(String lastName) { 
        this.lastName = lastName;
    }

    public void setAge(int age) { 
        this.age = age;
    }
}
模式 #5:开销较低的“读-写锁”策略

如果读操作远远超过写操作,您可以结合使用内部锁和 volatile 变量来减少公共代码路径的开销。

如下显示的线程安全的计数器,使用 synchronized 确保增量操作是原子的,并使用 volatile 保证当前结果的可见性。如果更新不频繁的话,该方法可实现更好的性能,因为读路径的开销仅仅涉及 volatile 读操作,这通常要优于一个无竞争的锁获取的开销。

@ThreadSafe
public class CheesyCounter {
    // Employs the cheap read-write lock trick
    // All mutative operations MUST be done with the 'this' lock held
    @GuardedBy("this") private volatile int value;

    //读操作,没有synchronized,提高性能
    public int getValue() { 
        return value; 
    } 

    //写操作,必须synchronized。因为x++不是原子操作
    public synchronized int increment() {
        return value++;

使用锁进行所有变化的操作,使用 volatile 进行只读操作。
其中,锁一次只允许一个线程访问值,volatile 允许多个线程执行读操作

2.8 JDK1.8 JVM内存模型概览

  • 1.8同1.7比,最大的差别就是:元数据区取代了永久代。元空间的本质和永久代类似,都是对JVM规范中方法区的实现。不过元空间与永久代之间最大的区别在于:元数据空间并不在虚拟机中,而是使用本地内存。

《java虚拟机_02_Jvm内存模型》

三 内存分配

3.1 新生、老年代分配

  • JVM初始分配的堆内存由-Xms指定,默认是物理内存的1/64;
  • JVM最大分配的堆内存由-Xmx指定,默认是物理内存的1/4。

默认空余堆内存小于40%时,JVM就会增大堆直到-Xmx的最大限制;空余堆内存大于70%时,JVM会减少堆直到-Xms的最小限制。因此服务器一般设置-Xms、-Xmx 相等以避免在每次GC 后调整堆的大小。

说明:如果-Xmx 不指定或者指定偏小,应用可能会导致java.lang.OutOfMemory错误,此错误来自JVM,不是Throwable的,无法用try…catch捕捉。

  • -Xmx –Xms
    指定最大堆和最小堆
    Xmx20m -Xms5m

  • Xmn
    设置新生代大小
    -XX:NewRatio

    • 新生代(eden+2*s)和老年代(不包含永久区)的比值
    • 4 表示 新生代:老年代=1:4,即年轻代占堆的1/5 3:5

-XX:SurvivorRatio
– 设置两个Survivor区和eden的比
– 8表示 两个Survivor :eden=2:8,即一个Survivor占年轻代的1/10

-Xmx20m -Xms20m -Xmn1m

《java虚拟机_02_Jvm内存模型》
根据实际事情调整新生代和幸存代的大小:官方推荐新生代占堆的3/8,幸存代占新生代的1/10

在OOM时,记得Dump出堆,确保可以排查现场问题

-Xmn和-Xmx之比大概是1:9,如果把新生代内存设置得太大会导致young gc时间较长
-Xmn:年轻代堆内存(与Xmx比为1:9) XX:NewRatio=9表示年老代与年轻代的比值为9:1
-Xmx:最大堆内存
-Xmx:最小堆内存
-XX:SurvivorRatio=8 表示Eden区与Survivor区的大小比值是8:1:1,因为Survivor区有两个

3.2 永久区分配

  • -XX:PermSize -XX:MaxPermSize
    设置永久区的初始空间和最大空间
    他们表示,一个系统可以容纳多少个类型

    JVM使用-XX:PermSize设置非堆内存初始值,默认是物理内存的1/64;由XX:MaxPermSize设置最大非堆内存的大小,默认是物理内存的1/4

-Xss256k //设置每个线程的堆栈大小
-Xss128k //设置每个线程的堆栈大小

3.3 栈大小分配

-Xss
通常只有几百K
决定了函数调用的深度
每个线程都有独立的栈空间
局部变量、参数 分配在栈上

    原文作者:java内存模型
    原文地址: https://blog.csdn.net/hardworking0323/article/details/81059166
    本文转自网络文章,转载此文章仅为分享知识,如有侵权,请联系博主进行删除。
点赞