likes
comments
collection
share

JVM:Java内存模型与运行时数据区域

作者站长头像
站长
· 阅读数 6

Java内存模型(Java Memory Model, JMM)

JVM:Java内存模型与运行时数据区域

Java内存模型(Java Memory Model, JMM)是Java虚拟机(JVM)在计算机内存中存储和管理数据的一种架构,JMM定义了一套规范来保证Java程序在多线程环境下的正确性

它是Java并发编程的基础,解决了多线程环境下的共享变量的可见性、原子性和有序性问题。此处的变量(V ariables)与Java编程中所说的变量有所区别,不包括局部变量与方法参数,因为他们是线程私有的,不会被共享,不会存在竞争问题。

以下是Java内存模型的一些关键概念:

  1. 主内存(Main Memory):主内存是一块存储共享变量的内存区域,所有线程都可以访问。当一个线程修改了某个共享变量的值,这个变化会被更新到主内存中。
  2. 工作内存(Working Memory):每个线程都有自己的工作内存,用于存储该线程使用的共享变量的副本。线程对共享变量的操作是在工作内存中进行的,并不直接操作主内存中的数据。当线程需要读取共享变量时,会从主内存中复制一份数据到工作内存;当线程需要写入共享变量时,会将修改后的值从工作内存刷新回主内存。
  3. 内存操作:Java内存模型定义了8种原子操作,如下:
    • lock(锁定):将主内存中的变量复制到工作内存。
    • unlock(解锁):将工作内存中的变量刷新回主内存。
    • read(读取):从主内存中读取变量的值。
    • load(加载):将read操作读取到的值加载到工作内存。
    • use(使用):从工作内存中读取变量的值,并在执行引擎中使用。
    • assign(赋值):将执行引擎中的值赋给工作内存中的变量。
    • store(存储):将assign操作赋值后的变量值写回主内存。
    • write(写入):将store操作写回的值更新到主内存中的变量。
  4. 内存屏障(Memory Barrier):内存屏障是一种硬件指令,用于确保内存操作的有序性。根据屏障的类型,它可以防止之前或之后的读写操作被重排序。
  5. 可见性:可见性是指当一个线程修改了共享变量的值,其他线程能够立即看到这个变化。Java内存模型通过工作内存和主内存的交互来确保可见性。
  6. 原子性:原子性是指一个操作要么全部完成,要么全部不完成,且在操作过程中不会被其他线程干扰。Java内存模型通过锁机制来保证原子性。
  7. 有序性:有序性是指程序的执行顺序应该与源代码中的顺序一致。Java内存模型通过内存屏障和锁机制来保证有序性。

为了确保正确性,Java内存模型还规定了一套Happens-Before规则。如果事件A Happens-Before事件B,那么事件A的结果对事件B可见。这些规则包括:

  1. 程序顺序规则:一个线程中的操作按照程序顺序发生。
  2. 监视器锁规则:解锁操作必须发生在同一个锁的后续加锁操作之前。
  3. volatile变量规则:对一个volatile变量的写操作必须发生在后续对该变量的读操作之前。
  4. 线程启动规则:线程的start()方法必须在该线程的任何其他操作之前发生。
  5. 线程终止规则:线程的终止操作(如线程完成或调用Thread.join()方法)必须在其他线程检测到该线程已经终止之前发生。
  6. 线程中断规则:线程的中断操作必须在被中断线程检测到中断之前发生。
  7. 对象终结规则:对象的构造函数完成后,对象的finalizer方法必须在对象被垃圾回收之前发生。
  8. 传递性:如果事件A Happens-Before事件B,且事件B Happens-Before事件C,那么事件A Happens-Before事件C。

通过遵循这些规则,Java内存模型确保了多线程程序的正确性。但是,实现这些规则可能会导致一定的性能开销。因此,在实际编程中,我们需要在确保多线程安全的前提下,尽量优化程序性能。

主内存(Main Memory)与 工作内存(Working Memory)

Java内存模型规定了所有的变量都存储在主内存(Main Memory)中,每条线程还有自己的工作内存(Working Memory),线程的工作内存中保存了被该线程使用的变量的主内存副本,线程对变量的所有操作(读取、赋值等)都必须在工作内存中进行,而不能直接读写主内存中的数据。

主内存与工作内存只是一个抽象概念,主内存可以类比于物理硬件的主内存,工作内存可以类比于处理器的高速缓存。所谓主内存副本,并不是将对象完全复制一份,而是复制对象的引用、对象中某个在线程访问到的字段。

内存屏障(Memory Barrier)

内存屏障(Memory Barrier,也称内存栅栏或内存屏障指令)是一种同步原语,用于确保内存操作的顺序性和一致性。在多处理器或多核系统中,处理器或编译器可能会对内存操作进行乱序执行(out-of-order execution)或指令重排(instruction reordering),以提高程序执行的性能。然而,在多线程环境下,这可能导致数据不一致和竞争条件。为了解决这个问题,内存屏障被引入以确保内存操作按照预期的顺序执行。不同语境下内存屏障有这不同的含义:

Linux语境下的内存屏障

在Linux内核中,内存屏障用于确保对共享数据的访问顺序和一致性。Linux内核提供了一系列内存屏障原语,如mb()(全局内存屏障)、rmb()(读内存屏障)和wmb()(写内存屏障)等。这些原语用于阻止处理器和编译器对内存操作进行乱序执行和指令重排,从而确保内存操作的正确顺序。

在Linux内核中,内存屏障原语通常与原子操作、锁机制和其他同步原语结合使用,以实现对共享数据的安全访问和修改。

Java中的内存屏障

在Java中,内存屏障通常与以下几种机制结合使用:

  1. volatile关键字volatile关键字用于声明共享变量,以确保变量的读写操作具有内存屏障的效果。当一个变量被声明为volatile时,JMM确保:
    • volatile变量的写操作会在读操作之前完成,即保证了可见性。
    • volatile变量的读写操作不会被编译器重排,即保证了有序性。
  2. synchronized关键字synchronized关键字用于实现同步代码块或方法,以确保在同一时刻只有一个线程可以访问共享数据。在进入和退出synchronized代码块时,JVM会插入内存屏障,以确保对共享数据的顺序访问。具体来说:
    • 在进入synchronized代码块时,JVM会插入一个获取锁操作(Monitor Enter)和一个读内存屏障(Load Barrier)。
    • 在退出synchronized代码块时,JVM会插入一个释放锁操作(Monitor Exit)和一个写内存屏障(Store Barrier)。
  3. java.util.concurrent包中的原子操作和锁机制java.util.concurrent包提供了一系列用于多线程编程的工具和原子操作类,如AtomicIntegerReentrantLockSemaphore等。这些类在实现时会使用内存屏障来确保数据一致性和线程安全。

Load Barrier(加载屏障)和Store Barrier(存储屏障)

在计算机系统中,Load Barrier(加载屏障)和Store Barrier(存储屏障)是两种内存屏障(memory barrier 或 memory fence)操作。它们在多处理器或多核处理器环境下起着关键作用,确保内存操作的正确执行顺序,避免在多线程并发执行过程中出现错误。

Load Barrier(加载屏障)

Load Barrier 主要用于确保在其之前的加载(load)操作被正确执行。当一个线程在多核处理器系统中执行时,处理器可能会对指令进行优化重排,这可能导致原本应该先执行的加载操作被推迟,从而引发错误。加载屏障可以防止这种现象,确保加载屏障之前的所有加载操作都在加载屏障处完成。

Store Barrier(存储屏障)

Store Barrier 与 Load Barrier 类似,但针对的是存储(store)操作。它确保存储屏障之前的所有存储操作都在存储屏障处完成。这有助于防止在多线程环境下,由于存储操作顺序的错误而导致的数据不一致问题。

实际上,Load Barrier 和 Store Barrier 可以分别看作是读屏障(read barrier)和写屏障(write barrier)。它们都起到同步内存访问的作用,使得在多处理器或多核处理器系统中的多线程程序更加安全、可靠。在某些情况下,还可以使用全屏障(full barrier),它同时包含了加载和存储屏障的功能,确保在屏障之前的所有内存操作(读和写)都被正确执行。

JVM运行时数据区域(The JVM Run-Time Data Areas)

JVM:Java内存模型与运行时数据区域

JVM运行时数据区域(The JVM Run-Time Data Areas)是指Java虚拟机在执行Java程序时所使用的不同内存部分。根据数据存储的类型和生命周期,JVM运行时数据区域可以分为以下几个部分:

  1. Java堆(Java Heap):Java堆用于存储对象实例和数组。堆内存中的数据可以被所有线程共享,因此需要考虑线程安全问题。Java堆是垃圾收集器(GC)管理的主要区域,当对象不再被引用时,它们会被GC自动回收。
  2. 方法区(Method Area):方法区(有时也称为永久代(Permanent Generation)或元空间(Metaspace,自Java 8起))用于存储类的元数据、静态变量、编译后的代码和常量池等信息。方法区是所有线程共享的,因此也需要关注线程安全问题。
  3. 虚拟机栈(Java Stack):虚拟机栈是用于存储局部变量、操作数和方法调用的数据结构。每个线程拥有自己的虚拟机栈,互不干扰。当线程调用一个方法时,JVM会在虚拟机栈上创建一个新的栈帧(Stack Frame),用于存储该方法的局部变量、操作数栈和方法调用信息。虚拟机栈内存分配和回收速度较快,但大小有限。
  4. 程序计数器(Program Counter):程序计数器是一个较小的内存区域,用于存储当前线程正在执行的字节码指令的地址。每个线程都有自己的程序计数器,线程之间互相独立。当线程执行一个方法时,程序计数器会跟踪该方法中的字节码指令流。
  5. 本地方法栈(Native Method Stack):本地方法栈类似于Java虚拟机栈,但用于存储本地方法(如JNI调用的C/C++方法)的调用信息。每个线程都有自己的本地方法栈。

Java堆(Java Heap): G1收集器视角

  • 《Java虚拟机规范》中规定, 所有的对象实例以及数组都应当在堆上分配。
  • Java堆是垃圾收集器管理的内存区域,Java堆更加细致的划分与不同的垃圾收集器有关

我们以G1收集器为例看一下Java堆的划分:

JVM:Java内存模型与运行时数据区域

JVM:Java内存模型与运行时数据区域

G1收集器将Java堆划分为多个大小相等的区域(Region),每个区域都可以根据需要扮演新生代或老年代的角色。在G1收集器中,堆的划分如下:

  1. Eden区(Eden Space):这是新生代的一部分,用于存放新创建的对象。当Eden区满时,会触发一次Minor GC。
  2. Survivor区(Survivor Space):这也是新生代的一部分,用于存放从Eden区经过一次Minor GC后依然存活的对象。Survivor区由两个部分组成,分别为from-spaceto-space。每次Minor GC后,依然存活的对象会在这两个区域之间转移。
  3. 老年代区(Old Generation):这部分区域用于存放经过多次Minor GC依然存活的对象以及大对象。当对象在Survivor区的年龄达到一定阈值时,它们会被晋升到老年代。
  4. 大对象区(Humongous Region):这部分区域用于存放大对象。在G1中,当对象的大小超过一个Region的一半时,它会被认为是一个大对象。大对象直接分配在老年代区。
  5. 可用区(Available Region):这部分区域没有被使用,可以根据需要分配给Eden区、Survivor区或老年代区。

虚拟机栈(Java Stack)与 本地方法栈(Native Method Stack)

JVM:Java内存模型与运行时数据区域

虚拟机栈(Java Stack)是Java虚拟机(JVM)运行时数据区的一部分,它与线程一一对应,是线程私有的。每个线程启动时,JVM都会为其分配一个独立的虚拟机栈。虚拟机栈主要用于存储局部变量、操作数栈、动态链接信息以及方法出入口等。在方法的执行过程中,栈帧(Stack Frame)是虚拟机栈的基本单位。

每当一个方法被调用时,JVM会为该方法创建一个新的栈帧,并将其压入调用线程的虚拟机栈中。当方法执行完毕后,对应的栈帧会被弹出并销毁。每个栈帧包含以下几个部分:

  1. 局部变量表(Local Variable Array):局部变量表用于存储方法的局部变量,包括基本数据类型(int、float、double等)、对象引用以及returnAddress类型。局部变量表的大小在编译时确定,单位为slot(局部变量槽)。
  2. 操作数栈(Operand Stack):操作数栈是一个后进先出(LIFO)的栈结构,用于存储方法执行过程中产生的临时数据。例如,在计算表达式a + b时,先将ab压入操作数栈,然后执行加法操作,最后将结果弹出并存储到局部变量表或其他地方。
  3. 动态链接(Dynamic Linking):动态链接用于支持方法调用过程中的符号引用解析。在栈帧中,动态链接主要体现为一个指向运行时常量池中方法引用的指针。
  4. 方法返回地址(Return Address):方法返回地址用于存储方法执行完毕后需要返回的指令地址。当方法正常完成或异常完成时,JVM会根据方法返回地址恢复调用者的执行环境,并继续执行调用者的代码。

虚拟机栈在JVM内存管理中扮演着重要角色,它为线程执行Java方法提供了空间和上下文环境。需要注意的是,虚拟机栈的容量是有限的,当栈的深度超过虚拟机允许的最大深度时,会抛出StackOverflowError异常;当虚拟机无法为新的栈帧分配足够的内存时,会抛出OutOfMemoryError异常。

**本地方法栈(Native Method Stack)**与虚拟机栈所发挥的作用是非常相似的,它们之间的区别不过是虚拟机栈为虚拟机执行Java方法(也就是字节码)服务,而本地方法栈则为虚拟机使用到的Native方法服务。有的虚拟机(譬如Sun HotSpot虚拟机)直接就把本地方法栈和虚拟机栈合二为一.

方法区(Method Area)

方法区(Method Area)是Java虚拟机(JVM)运行时数据区的一个组成部分。它主要用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等。方法区与Java堆一样,在内存中占用一块连续的空间,但它是线程共享的资源。

方法区的主要内容包括:

  1. 类信息:方法区存储了每个类的元数据,包括类名、访问修饰符、继承关系、方法信息、字段信息等。这些信息在类加载的时候被读取,并在运行时被用于创建类的实例对象。
  2. 运行时常量池:运行时常量池是方法区的一部分,用于存储编译期生成的字面量和符号引用。字面量包括字符串、整数、浮点数等,符号引用包括类和接口的全限定名、字段的名称和描述符、方法的名称和描述符等。运行时常量池在类加载的解析阶段被创建并填充。
  3. 静态变量:静态变量是类的属性,与类的实例无关。在类加载的初始化阶段,静态变量会被分配内存并赋予初始值。静态变量存储在方法区中,可以被类的所有实例共享。
  4. 即时编译器编译后的代码:为了提高Java程序的执行效率,JVM会使用即时编译器(JIT)对热点代码进行编译,将字节码转换为本地机器码。编译后的代码会被存储在方法区中,以便在运行时直接执行。

方法区的实现:

  • 在 1.6 之前的 HotSpot 虚拟机中,使用永久代(PermGen Space)来实现方法区
  • 从Java 8开始,方法区的实现已经发生了变化。原先的永久代(PermGen Space)被移除,取而代之的是元空间(Metaspace),它使用本地内存(Native Memory)来存储类元数据。

使用永久代实现方法区的内存模型:

JVM:Java内存模型与运行时数据区域

使用Metaspace实现方法区的内存模型:

JVM:Java内存模型与运行时数据区域

  • 永久代的大小由JVM参数MaxPermSize(-XX:MaxPermSize)决定 ,不设置也有默认大小,使用永久代来实现方法区导致了Java应用更容易遇到内存溢出的问题, 放到元空间后,只要没有触碰到进程可用内存的上限,例如32位系统中的4GB限制,就不会出问题。

本地内存(Native Memory)

本地内存(Native Memory)是指操作系统管理的内存,它与Java虚拟机(JVM)管理的内存(如Java堆、方法区等)是不同的概念。本地内存主要用于支持Java应用程序在运行过程中调用本地代码(如C/C++代码)以及存储本地资源(如文件、网络连接等)。

以下是一些涉及使用本地内存的场景:

  1. JNI(Java Native Interface):JNI允许Java代码调用本地方法(如C/C++代码)。在这种情况下,本地方法可能会分配本地内存来存储数据。这部分内存的管理需要由本地代码自行负责,JVM并不会对其进行垃圾回收。
  2. 直接内存(Direct Memory):直接内存是一种特殊类型的内存,它允许Java程序直接访问本地内存,从而提高I/O操作的性能。例如,java.nio包中的ByteBuffer类可以用来分配直接内存。直接内存的分配和回收不受JVM垃圾回收器的管理,需要程序员自己负责。
  3. 元空间(Metaspace):从Java 8开始,类元数据的存储从永久代(PermGen Space)迁移到了元空间。元空间使用本地内存来存储类信息,这使得类元数据的可用空间得到了显著扩展。
  4. 线程栈:线程栈是每个线程的私有内存区域,用于存储线程的栈帧。线程栈的内存分配在本地内存中,而不是JVM管理的内存。

本地内存的管理需要特别关注,因为它不受JVM垃圾回收器的管理。如果本地内存的使用不当,可能导致内存泄漏、内存溢出等问题。

直接内存(Direct Memory)

  • 在JDK 1.4中,引入了NIO(New Input/Output)类,该类提供了一种基于通道(Channel)和缓冲区(Buffer)的I/O方法。
  • 通过使用Native函数库,NIO可以直接在堆外内存中分配空间。
  • 同时,NIO会在Java堆内创建一个DirectByteBuffer对象,作为分配的堆外内存的引用,以便进行操作。
  • 这种方式在某些场景下能显著提升性能,因为它避免了在Java堆和Native堆之间反复拷贝数据
public static void main(String[] args) throws IOException {
        File file = new File("");
        FileInputStream fileInputStream = new FileInputStream(file);

        // 申请 100 字节的堆外内存
        ByteBuffer byteBuffer = ByteBuffer.allocate(100);
        FileChannel fileChannel = fileInputStream.getChannel();

        int len = 0;
        while ((len = fileChannel.read(byteBuffer)) != 1){
            byte[] bytes = byteBuffer.array();
            System.out.write(bytes,0,len);

            byteBuffer.clear();
        }
    }

使用直接内存的原因:

  • 减少了垃圾回收: 使用堆外内存的话,堆外内存是直接受操作系统管理( 而不是虚拟机 )。这样做的结果就是能保持一个较小的堆内内存,以减少垃圾收集对应用的影响。
  • 提升复制速度(io效率): 堆内内存由JVM管理,属于“用户态”;而堆外内存由OS管理,属于“内核态”。如果从堆内向磁盘写数据时,数据会被先复制到堆外内存,即内核缓冲区,然后再由OS写入磁盘,使用堆外内存避免了这个操作。

内存溢出

在Java中,内存溢出是指程序在运行过程中,由于分配的内存空间不足以满足实际需求,导致程序无法继续执行。以下是Java中常见的内存溢出类型:

  1. 堆内存溢出(Heap OutOfMemoryError):堆内存是Java虚拟机(JVM)管理的内存区域,用于存储对象实例和数组。堆内存溢出通常发生在由于创建的对象过多,垃圾回收器无法及时回收无用对象,导致可用堆内存不足。此时,JVM会抛出java.lang.OutOfMemoryError: Java heap space异常。解决方法包括调整堆内存大小、优化垃圾回收策略或者检查并修复程序中的内存泄漏问题。
  2. 栈内存溢出(StackOverflowError):栈内存是每个线程的私有内存区域,用于存储线程的栈帧(包括局部变量、操作数栈等)。栈内存溢出通常发生在方法调用深度过大(如递归调用),导致栈空间不足。此时,JVM会抛出java.lang.StackOverflowError异常。解决方法包括调整栈内存大小或者优化程序逻辑,以减少方法调用深度。
  3. 方法区溢出(Metaspace/PermGen OutOfMemoryError):方法区(在Java 8之前称为永久代)是用于存储类元数据、常量池等信息的内存区域。方法区溢出通常发生在加载的类过多,导致可用方法区空间不足。在Java 8之前,JVM会抛出java.lang.OutOfMemoryError: PermGen space异常;在Java 8及以后,JVM会抛出java.lang.OutOfMemoryError: Metaspace异常。解决方法包括调整方法区大小或者检查并修复程序中的类加载问题(如类加载泄漏等)。
  4. 直接内存溢出(Direct Memory OutOfMemoryError):直接内存是操作系统管理的内存空间,用于存储直接缓冲区等资源。直接内存溢出通常发生在分配的直接内存过多,导致可用内存不足。由于直接内存的分配和回收不受JVM垃圾回收器的管理,因此需要程序员自己负责。当直接内存溢出时,JVM会抛出java.lang.OutOfMemoryError: Direct buffer memory异常。解决方法包括调整直接内存大小或者检查并修复程序中的直接内存管理问题(如内存泄漏等)。

参考

Java Hotspot G1 GC的一些关键技术

coderDu

volatile与内存屏障总结