Java内存模型(Java Memory Model, JMM)
Java内存模型(Java Memory Model, JMM)是Java虚拟机(JVM)在计算机内存中存储和管理数据的一种架构,JMM定义了一套规范来保证Java程序在多线程环境下的正确性
它是Java并发编程的基础,解决了多线程环境下的共享变量的可见性、原子性和有序性问题。此处的变量(V ariables)与Java编程中所说的变量有所区别,不包括局部变量与方法参数,因为他们是线程私有的,不会被共享,不会存在竞争问题。
以下是Java内存模型的一些关键概念:
- lock(锁定):将主内存中的变量复制到工作内存。
- unlock(解锁):将工作内存中的变量刷新回主内存。
- read(读取):从主内存中读取变量的值。
- load(加载):将read操作读取到的值加载到工作内存。
- use(使用):从工作内存中读取变量的值,并在执行引擎中使用。
- assign(赋值):将执行引擎中的值赋给工作内存中的变量。
- store(存储):将assign操作赋值后的变量值写回主内存。
- write(写入):将store操作写回的值更新到主内存中的变量。
为了确保正确性,Java内存模型还规定了一套Happens-Before规则。如果事件A Happens-Before事件B,那么事件A的结果对事件B可见。这些规则包括:
通过遵循这些规则,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中,内存屏障通常与以下几种机制结合使用:
volatile
关键字:volatile
关键字用于声明共享变量,以确保变量的读写操作具有内存屏障的效果。当一个变量被声明为volatile
时,JMM确保:
- 对
volatile
变量的写操作会在读操作之前完成,即保证了可见性。 - 对
volatile
变量的读写操作不会被编译器重排,即保证了有序性。
synchronized
关键字:synchronized
关键字用于实现同步代码块或方法,以确保在同一时刻只有一个线程可以访问共享数据。在进入和退出synchronized
代码块时,JVM会插入内存屏障,以确保对共享数据的顺序访问。具体来说:
- 在进入
synchronized
代码块时,JVM会插入一个获取锁操作(Monitor Enter)和一个读内存屏障(Load Barrier)。 - 在退出
synchronized
代码块时,JVM会插入一个释放锁操作(Monitor Exit)和一个写内存屏障(Store Barrier)。
java.util.concurrent
包中的原子操作和锁机制:java.util.concurrent
包提供了一系列用于多线程编程的工具和原子操作类,如AtomicInteger
、ReentrantLock
、Semaphore
等。这些类在实现时会使用内存屏障来确保数据一致性和线程安全。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运行时数据区域(The JVM Run-Time Data Areas)是指Java虚拟机在执行Java程序时所使用的不同内存部分。根据数据存储的类型和生命周期,JVM运行时数据区域可以分为以下几个部分:
当前线程正在执行的字节码指令的地址
。每个线程都有自己的程序计数器,线程之间互相独立。当线程执行一个方法时,程序计数器会跟踪该方法中的字节码指令流。Java堆(Java Heap): G1收集器视角
- 《Java虚拟机规范》中规定, 所有的对象实例以及数组都应当在堆上分配。
- Java堆是垃圾收集器管理的内存区域,
Java堆更加细致的划分与不同的垃圾收集器有关
我们以G1收集器为例看一下Java堆的划分:
G1收集器将Java堆划分为多个大小相等的区域(Region),每个区域都可以根据需要扮演新生代或老年代的角色。在G1收集器中,堆的划分如下:
from-space
和to-space
。每次Minor GC后,依然存活的对象会在这两个区域之间转移。虚拟机栈(Java Stack)与 本地方法栈(Native Method Stack)
虚拟机栈(Java Stack)是Java虚拟机(JVM)运行时数据区的一部分,它与线程一一对应,是线程私有的。每个线程启动时,JVM都会为其分配一个独立的虚拟机栈。虚拟机栈主要用于存储局部变量、操作数栈、动态链接信息以及方法出入口等。在方法的执行过程中,栈帧(Stack Frame)是虚拟机栈的基本单位。
每当一个方法被调用时,JVM会为该方法创建一个新的栈帧,并将其压入调用线程的虚拟机栈中。当方法执行完毕后,对应的栈帧会被弹出并销毁。每个栈帧包含以下几个部分:
a + b
时,先将a
和b
压入操作数栈,然后执行加法操作,最后将结果弹出并存储到局部变量表或其他地方。虚拟机栈在JVM内存管理中扮演着重要角色,它为线程执行Java方法提供了空间和上下文环境。需要注意的是,虚拟机栈的容量是有限的,当栈的深度超过虚拟机允许的最大深度时,会抛出StackOverflowError
异常;当虚拟机无法为新的栈帧分配足够的内存时,会抛出OutOfMemoryError
异常。
**本地方法栈(Native Method Stack)**与虚拟机栈所发挥的作用是非常相似的,它们之间的区别不过是虚拟机栈为虚拟机执行Java方法(也就是字节码)服务,而本地方法栈则为虚拟机使用到的Native方法服务。有的虚拟机(譬如Sun HotSpot虚拟机)直接就把本地方法栈和虚拟机栈合二为一
.
方法区(Method Area)
方法区(Method Area)是Java虚拟机(JVM)运行时数据区的一个组成部分。它主要用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等。方法区与Java堆一样,在内存中占用一块连续的空间,但它是线程共享的资源。
方法区的主要内容包括:
方法区的实现:
- 在 1.6 之前的 HotSpot 虚拟机中,使用
永久代(PermGen Space)
来实现方法区 - 从Java 8开始,方法区的实现已经发生了变化。原先的永久代(PermGen Space)被移除,取而代之的是
元空间(Metaspace)
,它使用本地内存(Native Memory)
来存储类元数据。
使用永久代实现方法区的内存模型:
使用Metaspace实现方法区的内存模型:
- 永久代的大小由JVM参数MaxPermSize(-XX:MaxPermSize)决定 ,不设置也有默认大小,使用永久代来实现方法区导致了Java应用更容易遇到内存溢出的问题, 放到元空间后,只要没有触碰到进程可用内存的上限,例如32位系统中的4GB限制,就不会出问题。
本地内存(Native Memory)
本地内存(Native Memory)是指操作系统管理的内存,它与Java虚拟机(JVM)管理的内存(如Java堆、方法区等)是不同的概念。本地内存主要用于支持Java应用程序在运行过程中调用本地代码(如C/C++代码)以及存储本地资源(如文件、网络连接等)。
以下是一些涉及使用本地内存的场景:
java.nio
包中的ByteBuffer
类可以用来分配直接内存。直接内存的分配和回收不受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中常见的内存溢出类型:
java.lang.OutOfMemoryError: Java heap space
异常。解决方法包括调整堆内存大小、优化垃圾回收策略或者检查并修复程序中的内存泄漏问题。java.lang.StackOverflowError
异常。解决方法包括调整栈内存大小或者优化程序逻辑,以减少方法调用深度。java.lang.OutOfMemoryError: PermGen space
异常;在Java 8及以后,JVM会抛出java.lang.OutOfMemoryError: Metaspace
异常。解决方法包括调整方法区大小或者检查并修复程序中的类加载问题(如类加载泄漏等)。java.lang.OutOfMemoryError: Direct buffer memory
异常。解决方法包括调整直接内存大小或者检查并修复程序中的直接内存管理问题(如内存泄漏等)。参考
Java Hotspot G1 GC的一些关键技术
coderDu
volatile与内存屏障总结