1.1 概述
对于Java程序员来说,再虚拟机自动内存管理机制的帮助下,不再需要为每一个new操作去写配对的delete/free代码,不容易出现内存泄露和内存溢出的问题,看起来由虚拟机管理内存一切都很美好。不过正是因为Java程序员把控制内存的权利交给来Java虚拟机,一旦出现内存泄露方面的问题,如果不了解虚拟机是怎样使用内存的,那排查错误、修正问题将会称为意向艰难的工作。
1. 2 运行时数据区域
java虚拟机在执行java程序的过程中会把它所管理的内存划分为若干不同的数据区域。这些区域各有用途,以及创建和销毁的时间,有的区域会随着虚拟机进程的启动而一直存在,有些区域是依赖用户线程的启动和结束而建立和销毁的。
- 程序计数器(Propgram Counter Register)
它是一块较小的内存空间,它可以看作是当前线程所执行的字节码的行号指示器。(字节码解释器工作时就是通过改变这个计数器的值选取下一条需要执行的指令,它是程序控制流的指示器)。每个线程都有自己的程序计数器,以便在多线程之间轮流切换时,能够恢复到当前线程程序执行时正确的位置。
异常情况
这部分内存空间通常不会发生异常
- Java虚拟机栈
Java虚拟机栈也是线程私有的,它的生命周期和线程相同。虚拟机栈描述的是Java方法执行的时候,Java虚拟机都会同步一个栈帧用于存储局部变量表、操作数栈、动态连接、方法出口等信息,每一个方法被调用直至执行完毕的过程中,就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程。
局部变量表
:存放了编译期可知的各种Java虚拟机基本类型、对象引用类型。这些数据类型在局部变量表中的存储空间会以局部变量槽来表示,其中64位长度的long和double会占用两个变量槽,其余数据类型只占用一个。局部变量表所需要的内存空间在编译期间完成分配,当进入一个方法时,这个方法需要在栈帧中分配多大的局部变量空间时是完全确认的,且在方法运行期间,这个空间大小不会改变。
异常情况StackOverflowError
:线程请求的栈深度大于虚拟机所允许的深度OutOfMemoryError
:Java虚拟机栈容量如果可以动态扩展,当栈扩展时无法申请到足够的内存会抛出该异常(注意HotSpot虚拟机是不允许栈容量动态扩展的,如果线程无法申请到虚拟机栈空间时就会发生OOM异常)
- 本地方法栈
本地方法栈(Native Method Stacks)和Java虚拟机栈发挥的作用是相似的,只是它不是用于Java 语言,而是用于为虚拟机使用本地方法服务。
简单地讲,一个Native Method是一个Java调用非Java代码的接囗。一个Native Method是这样一个Java方法:该方法的实现由非Java语言实现,比如C。这个特征并非Java所特有,很多其它的编程语言都有这一机制,比如在C中,你可以用extern “c” 告知c编译器去调用一个c的函数。定义一个native method时,并不提供实现体(有些像定义一个Java interface),因为其实现体是由非java语言在外面实现的。
异常情况
和虚拟机栈一样
- Java堆
对于Java应用来说,Java堆是虚拟机所管理的内存中最大的一块。其是被所有线程所共享的一块内存区域,在虚拟机启动时创建,这块空间的唯一作用就是存放对象实例。Java堆时垃圾收集器管理的内存区域,因此有些资料也将其称为“GC堆”。根据《Java虚拟机规范》规定,Java堆可以处于物理上不连续的内存空间,但在逻辑上它应该被视为连续的。
异常情况OutOfMemoryError
:Java堆可以被实现成固定的大小,也是可以扩展的(目前主流的虚拟机都是实现了可扩展的),当Java堆中没有内存完成实例分配,并且堆也无法进行扩展时,Java虚拟机会抛出该异常。
- 方法区
方法区与java堆一样,时各个线程所共享的(从上图可以总结颜色较淡的区域都是线程私有的,而颜色较深的区域都是线程可以共享的)。它用于存储已被虚拟机加载的类型信息、常量变量、即时编译器编译后的代码缓存等数据。
类型信息
:拟机加载的类型信息指的是Java虚拟机在运行Java程序时加载类的元数据,包括类的结构、方法、字段等信息,用于实例化对象、执行方法和访问字段等操作。这些信息通过类加载器加载到内存中,确保类的正确性和安全性。
异常情况OutOfMemoryError
:方法区无法满足新的内存分配需求
- 运行时常量池
该内存区域是方法区的一部分。Class文件除了有类的版本、字段、方法、接口等描述信息外,还有一项信息是常量池,用于存放编译期生成的各种字面量与符号引用,这部分内容将在类加载后存放到方法区的运行时常量池中。运行时常量池相对于Class文件常量池的另一个重要特征是具备动态性,java语言并不要求常量一定只在编译器才能产生,也就是说,并非预置入Class文件中常量池的内容才能进入方法区运行时常量池,运行期间也可以将新的常量池放入池中,这种特性被开发人员利用得比较多的是String类的intern()方法。
String类的intern()方法是用来实现字符串常量池的概念。它的作用是将一个字符串对象添加到字符串常量池中,并返回字符串常量池中的引用。如果字符串常量池中已经存在相同内容的字符串,则直接返回常量池中的引用,否则将该字符串添加到常量池中。
public class StringInternExample {
public static void main(String[] args) {
// 创建两个字符串对象
String str1 = new String("Hello");
String str2 = new String("Hello");
// 使用intern()方法将字符串添加到常量池中
String str3 = str1.intern();
String str4 = str2.intern();
// 检查字符串的引用是否相同
boolean areSame1 = (str1 == str2); // false,因为它们是两个不同的对象
boolean areSame2 = (str1 == str3); // false,因为str1不在常量池中
boolean areSame3 = (str3 == str4); // true,因为它们都在常量池中
System.out.println("Are str1 and str2 the same? " + areSame1);
System.out.println("Are str1 and str3 the same? " + areSame2);
System.out.println("Are str3 and str4 the same? " + areSame3);
}
}
异常情况OutOfMemoryError
:因为运行时常量池是方法区的一部分,所以其和方法区分一样拥有一样的异常情况
- 直接内存
直接内存并不是运行时数据区的一部分,但是这部分内存也被频繁的使用。我们这里可以将直接内存理解为非Java管理的运行时内存的其它内存区域。它就是一块操作系统管理的实际内存。很多情况下我们会使用到这块内存。
异常情况OutOfMemoryError
:显然本机内存的分配不会受到Java堆的大小的限制,但是既然是内存,肯定受到本机总内存的影响,如果忽略直接内存,使得各个内存区域总和大雨物理时机内存,就会出现该异常。
1. 3 HotSpot虚拟机对象探秘
介绍完Java虚拟机的运行时数据区域之后,我们大致明白了Java虚拟机内存模型概括,现在我们进一步分析这些内存区域是如何创建、如何布局以及如何访问的。在此,我们借助HosSpot这一常用的虚拟机来分析。
- 对象的创建
Java是面向对象的语言,在代码中创建一个新的对象一个new关键字即可,但在虚拟机层面却要经历一个比较复杂的过程。具体的过程可以概括如下:
- 当虚拟机遇到一条字节码new指令时,会将去检查这个指令的参数是否能在常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已被加载、解析和初始化过。如果没有,首先执行相应的类加载过程。
- 类加载过程完成后,接下来虚拟机将为新生对象分配内存(类加载后对象的大小是已经确定了的),为对象分配空间的任务实际上就是把一块确定大小的内存块中堆中划分出来。
指针碰撞分配方式
:这种情况出现在堆的内存是规整的,即多有使用过的内存放在一边没有使用过的内存放在另一边,中间放着一个指针作为分界点,分配内存就是指针向空闲块挪动的过程。空闲列表分配方式
:这种情况出现在堆的内存不是规整的,所以虚拟机维护了一个列表,记录哪些内存块是空闲的。
- 接下来,Java虚拟机要对对象进行必要的设置,例如这个对象是哪个类的实例、如何才能找到类的元数据信息、对象的hash码(实际上hash码会延迟到真正调用Object::hashCode()方法时才计算)、对象的GC分代年龄信息。这些信息存放在对象头中。
- 上面工作完成后,虚拟机层面对象就已经创建完毕了,当Java视角来看,对象创建才刚刚开始—构造函数,即Class文件中的<init>()方法还没有执行,所有的字段都是默认值,对象需要的其它资源还没有安装预定的意图构造好。new指令后会执行<init>()方法,按照程序员的意愿对对象进行初始化,此时一个真正有意义的对象才算构造完毕。
- 对象的内存布局
前面我们分析了当创建新的对象,虚拟机具体是如何为新对象分配堆内存空间的,现在我们来分析一下新的对象在这块内存空间是如何布局的。
在Hotspot虚拟机中,对象在堆内存中的存储布局可以划分为三个部分:对象头、实例数据和对齐填充。
对象头
HotSpot虚拟机对象头包括两类信息,第一类
是存储对象自身的运行时数据,如hash码、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等(称为MARK Word)。对象存储的运行时数据很多,其实超过了32、64位结构所能记录的最大限度,但对象头里的信息是与对象自身定义的数据无关的额外成本,考虑到虚拟机的空间效率,Mark Word被设计成了一个有着动态定义的数据结构,以便在极小的空间存储尽量多的数据,根据对象的状态复用自己的存储空间。例如在32位的HotSpot虚拟机中,如对象未被同步锁锁定的状态下,Mark Word的32个比特存储空间中25个比特用于存储对象的Hash码,4个比特用于存储对象分代年龄,2个比特用于存储锁标志位,1个比特固定0,在其他状态(轻量级锁定、重量级锁定、GC标记、可偏向)下对象的存储内容如下:
存储内容 | 标志位 | 状态 |
---|---|---|
对象哈希码、对象分代年龄 | 01 | 未锁定 |
执行锁记录的指针 | 00 | 轻量级锁定 |
执行重量级锁的指针 | 10 | 膨胀(重量级锁定) |
空,不需要记录信息 | 11 | GC标记 |
偏向线程ID、偏向时间戳、对象分代年龄 | 01 | 可偏向 |
对象头的另外一部分
是类型指针,即对象只想它的类型元数据指针,Java虚拟机通过这个指针来确定该对象是哪个类的实例(其实不一定需要这个指针来找到该属性属于哪个对象的实例)。如果对象是一个Java数组,那在对象头中还必须有一块用于记录数组长度的数据,因为虚拟机可以通过普通Java对象的元数据确定Java对象的大小,但无法确定数组长度的大小。
实例数据
实例数据部分是对象真正存储的有效信息,即我们在程序代码里所定义的各种类型字段的内容,无论是从父类继承下来的,还是在子类中定义的字段都必须记录起来。这部分的存储顺序会受到虚拟机分配策略参数(-XX:FieldsAllocationStyle
参数)和字段在Java源码中定义顺序的影响。HotSpot虚拟机默认的分配顺序为longs/doubles(8字节)、ints(4字节)、shots/chars(2字节)、bytes/booleans(8字节)、oops,从以上默认的分配策略中可以看到,相同宽度的字段总是被分配到一起存放,当然可以通过+XX:CompactFileds
参数值为true,那子类之中较窄的变量可以插入父类变量的空隙中,来节省空间。
对齐填充
这部分没有什么特别含义,仅仅起着一个占位符的作用,因为HotSpot虚拟机自动内存管理系统对象的起始地址必须是8字节的整数倍,而对象头部分是严格是8字节的倍数,而实例数据部分的大小是不确定的,所以需要对象填充部分来进行补全。
- 对象的访问定位
创建对象就是为了使用,Java程序通过栈上的refrence数据来操作堆上的具体对象。引用主要采用两种主流的访问方式区定位、访问到堆中的对象的具体位置:
- 如果使用
句柄访问
的话(句柄(Handle)是一个用来标识对象或者项目的标识符),java堆中将可能会划分出一块内存来作为句柄池,reference中存储的就是对象的句柄地址,而句柄中包含了对象实例数据与类型数据个字具体的地址信息。
2. 如果使用直接指针访问
的话,Java堆中对象的内存布局就必须考虑如何放置访问类型数据的相关信息,reference中存储的直接就是对象地址,如果只是访问对象本身的话,就不需要多一次间接访问的开销。
上面两种方式各有优势,使用句柄访问的最大好处就是reference中存储的是稳定的句柄地址,在对象移动(垃圾回收中这是很普遍的行为)时只用改变句柄中的实例指针,而reference本身不需要修改。使用直接指针访问最大的好处就是速度很快就,它节省了一次指针定位的时间开销,由于对象访问在Java中十分频繁,因此这类开销积少成多也是一项极为可观的执行成本,HotSpot就是采用的第二种方式。
1. 4 作业:OutOfMemoryError异常体验
前面的学习中,发现OutOfMemoryError是内存管理时最容易出现的异常。本次作业分为两个部分:
- 通过代码验证各个运行时区域的存储内容
- 通过异常信息迅速定位到发生异常的内存区域
- java堆溢出
Java堆用于存储对象实例,我们只需要不断地创建对象,并且保证GC Roots到对象之间的可达路径来避免垃圾回收机制清楚这些对象,那么随着对象的增加,总容量触及最大堆容量限制后会产生内存溢出异常。 这里再加入参数`-XX:+HeapDumpOnOutOfMemoryError可以让虚拟机在出现内存溢出时Dump出当前的内存堆转存储快照以便事后分析。
-Xms5M -Xmx5M -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=JVM_analysis
public class jvmtestMain {
//静态内部类
static class OOMObject{
}
public static void main(String[] args) {
List<OOMObject> list=new ArrayList<OOMObject>();
while(true){
list.add(new OOMObject());
}
}
}
等一段时间后出现下面的错误
这里告诉我们,是我们的堆内存溢出,而且自动帮我们生成了内存映像JVM_analysis
。要解决这个内存溢出的异常,常规的处理方法是首先通过内存印象分析工具堆Dump出来的堆转储快照进行分析。
下载内存映像分析工具:https://visualvm.github.io/download.html
- 虚拟机栈和本地方法栈溢出
由于HotSpot虚拟机中并不区分虚拟机栈和本地方法栈,因此对于HotSpot来说,-Xoss参数(设置本地方法捉栈大小)虽然存在,但实际上没有任何效果,栈容量只能由-Xss参数来设定。前面我们介绍到,HotSpot虚拟机的栈是不支持扩展的,所以除非在创建线程申请内存时就因无法获得足够内存而出现OutOfMemoryError异常,否则在线程运行时不会因为扩展导致内存溢出的,只会因为栈容量无法容纳新的栈帧而导致StackOverflowError异常,为了进行验证,我们进行了两个实验,先将实验范围限制在单线程中操作,尝试下面两种行为让HotSpot产生OutOfMemoryError异常:
- 使用-Xss参数减少栈内存容量(会抛出StackOverflowError异常,异常出现时输出的堆栈深度相应缩小)
-Xss128k
public class Main {
private int stackLength=1;
public void stackLeak(){
stackLength++;
stackLeak();
}
public static void main(String[] args) throws Throwable {
Main oom=new Main();
try{
oom.stackLeak();
}catch (Throwable e)
{
System.out.println("stackLength" + oom.stackLength);
throw e;
}
}
}
注意:不同版本的Java虚拟机允许的最小栈容量是不同的
- 定义大量本地变量,增大此方法帧中本地变量表的长度。(抛出StackOverflowError,异常出现时输出的栈深度相应变小)
public class Main {
private static int stackLength=0;
public static void stackLeak(){
long a0,a1,a2,a3,a4,a5,a6,a7,a8,a9,a10,
a11,a12,a13,a14,a15,a16,a17,a18,
a19,a20,a21,a22,a23,a24,a25,a26,
a27,a28,a29,a30,a31,a32,a33,a34,
a35,a36,a37,a38,a39,a40,a41,a42,
a43,a44,a45,a46,a47,a48,a49,a50,
a51,a52,a53,a54,a55,a56,a57,a58,
a59,a60,a61,a62,a63,a64,a65,a66,
a67,a68,a69,a70,a71,a72,a73,a74,
a75,a76,a77,a78,a79,a80,a81,a82,
a83,a84,a85,a86,a87,a88,a89,a90,
a91,a92,a93,a94,a95,a96,a97,a98,
a99,a100=0;
stackLength++;
stackLeak();
a0=a1=a2=a3=a4=a5=a6=a7=a8=a9=
a10=a11=a12=a13=a14=a15=a16=a17
=a18=a19=a20=a21=a22=a23=a24=a25
=a26=a27=a28=a29=a30=a31=a32=a33
=a34=a35=a36=a37=a38=a39=a40=a41
=a42=a43=a44=a45=a46=a47=a48=a49
=a50=a51=a52=a53=a54=a55=a56=a57
=a58=a59=a60=a61=a62=a63=a64=a65=
a66=a67=a68=a69=a70=a71=a72=a73=a74=
a75=a76=a77=a78=a79=a80=a81=a82=a83
=a84=a85=a86=a87=a88=a89=a90=a91=a92
=a93=a94=a95=a96=a97=a98=a99=a100=0;
}
public static void main(String[] args) throws Throwable {
try{
stackLeak();
}catch (Error e){
System.out.println("stackLength:"+stackLength);
throw e;
}
}
}
上面实验结果表明,无论是栈帧太大还是虚拟机栈容量太小,当新的栈帧无法分配内存时,HotSpot都会抛出StackOverflowError异常。
- 方法区和运行时常量池溢出
由于运行时常量池时方法区的一个部分,所以这两个区域溢出测试可以放在一起进行。String::intern()是一个本地方法,它的作用是如果字符串常量池中已经包含一个等于此String对象的字符串,则返回代表池中这个字符串String得引用;否则会讲此String对象包含的字符串添加到常量池中,并返回此常量的引用。在JDK6之前的HotSpot中,常量池都分配在永久代中,可以通过 -XX:PermSize
和-XX:MaxPermSize
限制永久代的大小,间接达到现在常量池大小的目的。
public static void main(String[] args){
Set<String> set=new HashSet<String>();
short i=0;
while(true){
set.add(String.valueOf(i++).intern());
}
而使用JDK 7或更高版本的JDK,使用上面两个参数会把方法区限制在6MB,循环将一直进行下去,永不停歇,这是因为存放在永久代的字符串常量池被移动到Java堆中了,所以上面两个限制永久代大小的参数不能在现在常量池的大小了。此时使用-Xmx
参数限制最大堆到6MB就可以看到以下结果:
关于字符串常量池的实现哪里出现问题,引出了一些更有意思的影响
public static void main(String[] args){
String str1=new StringBuilder("计算机").append("软件").toString();
System.out.println(str1.intern()==str1);
String str2=new StringBuilder("ja").append("va").toString();
System.out.println(str2.intern()==str2);
}
对于上面代码,jdk6中运行结果为两个false,而JDK7中运行会得到true和false。这是因为jdk6会把首次遇到的字符串实例复制到永久代字符串常量池中存储,返回的也是永久代里面堆这个字符串实例的引用,而由StringBuilder创建的字符串存储在Java堆上,所以得到的会是false。而JDK7的intern()方法实现就不需要再拷贝字符串实例到永久代了,既然字符串已经移到Java堆中,那只需要在常量池中保存堆首次出现的实例的引用即可。所以第一个输出为true,而第二个为false是因为java这个字符串在执行StringBuilder.toString()之前就已经出现过了,字符串常量池有它的引用,不符合intern()方法要求首次遇到的原则。
前面已经介绍了常量池溢出的情况,下面具体再介绍一下方法区。我们知道方法区主要职责是存放类型的相关信息,如类名、访问修饰符、常量池、字段描述、方法描述等。对于这部分区域的测试,基本实现就是产生大量的类来填满方法区,知道溢出为止。这里使用CGlib动态代理来生成大量的动态类来填充方法区。
-XX:PermSize=10M -XX:MaxPermSize=10M
注意导入cglib的依赖包
public class Main {
public static void main(String[] args) throws Throwable {
while(true){
Enhancer enhancer=new Enhancer();
enhancer.setSuperclass(OOMObject.class);
enhancer.setUseCache(false);
enhancer.setCallback(new MethodInterceptor() {
@Override
public Object intercept(Object o, Method method, Object[] objects, MethodProxy methodProxy) throws Throwable {
return methodProxy.invokeSuper(o,args);
}
});
enhancer.create();
}
}
static class OOMObject{
}
}
方法区溢出也是一种常见的内存溢出异常。在JDK 8以后,永久代便完全退出了历史的舞台,元空间作为其替代者出场。在默认设置下,前面列举的那些正常的动态创建类型测试用例已经很难再迫使虚拟机产生方法区的溢出异常了。不过为了让使用者有预防实际应用里出现类似于上面代码那样的破坏性操作,HotSpot还是提供了一些参数作为元空间的防御措施:
-
-XX:MaxMetaspaceSize
:设置元空间最大值,默认为-1,即不限制,或者说只受限于本地内存大小 -
-XX:MetaspaceSize
:指定元空间的初始大小,以字节为单位,达到该值就会触发垃圾收集进行类型卸载,同时收集器会对该值进行调整:如果释放了大量空间,就降低该值;如果释放了少量空间,那么在不超过-XX:MaxMetaspaceSize
的情况下,适当提升该值 -
-XX:MinMetaspaceFreeRatio
:作用是在垃圾收集之后控制最小的元空间剩余容量的百分比,可减少因为元空间不足导致的垃圾收集频率。类似的还有-XX:MaxMetaspaceFreeRatio
,用于控制最大元空间剩余容量百分比。
- 本机直接内存溢出
直接内存的容量可以通过-XX:MaxDirectMemorySize
参数来控制,如果不去指定默认大小为Java堆的最大值一样。下面代码通过直接反射获取Unsafe实例进行内存分配(Unsafe类的getUnsafe()方法指定只有引导类加载才能返回实例,体现了设计者希望只有虚拟机标志类库里面的类才能使用Unsafe功能),因为虽然使用DirectByteBuffer分配内存也会抛出内存溢出异常,但它抛出异常并没有真正向操作系统申请内存空间,而是通过计算得知内存无法分配就会在代码里手动抛出溢出异常,真正分配内存的方法是Unsafe.allocateMemory()
Unsafe 类是 Java 中的一个特殊类,它位于 sun.misc 包下,通常不建议直接使用它,因为它提供了对底层内存和其他系统级操作的直接访问,可能会导致不安全的操作和内存泄漏。尽管如此,Unsafe 类在一些高性能、底层和系统级编程中具有一定的用途,但需要小心谨慎地使用。
-Xms1M -XX:MaxDirectMemorySize=1M
public class Main {
private static final int _1MB=1024*1024;
public static void main(String[] args) throws Exception {
Field unsafeFiled= Unsafe.class.getDeclaredFields()[0];
unsafeFiled.setAccessible(true);
Unsafe unsafe=(Unsafe) unsafeFiled.get(null);
while(true){
unsafe.allocateMemory(_1MB);
}
}
}
由直接内存导致的溢出,一个明显的特点就是在Heap Dump文件中不会看见什么明显的异常情况,如果发现内存溢出后Dump文件很小,而程序又直接或间接使用了DirectMemory(典型的就是NIO),那就可以考虑检查一下直接内存方面的原因了。
DirectByteBuffer 是 Java 中的一个类,它是 Java NIO(New I/O)库的一部分,用于高效处理直接内存(Direct Memory)缓冲区。DirectByteBuffer 主要用于将数据存储在堆外内存(off-heap memory),而不是常规的 Java 堆内存中。这样可以提高数据读写的性能,特别是在处理大量数据时。DirectByteBuffer 的构造函数通常不直接使用,而是通过调用其他类的方法来创建,比如 ByteBuffer.allocateDirect()。下面是一个简单的示例,展示如何创建和使用 DirectByteBuffer:文章来源:https://www.toymoban.com/news/detail-725846.html
import java.nio.ByteBuffer;
public class DirectByteBufferExample {
public static void main(String[] args) {
// 创建一个直接内存缓冲区,大小为 1024 字节
ByteBuffer buffer = ByteBuffer.allocateDirect(1024);
// 向缓冲区写入数据
String data = "Hello, DirectByteBuffer!";
buffer.put(data.getBytes());
// 切换到读模式
buffer.flip();
// 从缓冲区读取数据并输出
byte[] outputData = new byte[buffer.remaining()];
buffer.get(outputData);
System.out.println(new String(outputData));
// 清空缓冲区,准备再次写入数据
buffer.clear();
// 释放缓冲区(可选操作,不是必须的)
// ((DirectBuffer) buffer).cleaner().clean();
}
}
在上面的示例中,我们首先使用 ByteBuffer.allocateDirect() 创建了一个直接内存缓冲区,然后向缓冲区写入字符串数据。接下来,我们切换缓冲区到读模式,并从中读取数据并输出。最后,我们清空缓冲区,以便再次写入数据。文章来源地址https://www.toymoban.com/news/detail-725846.html
到了这里,关于Step2:Java内存区域与内存溢出异常的文章就介绍完了。如果您还想了解更多内容,请在右上角搜索TOY模板网以前的文章或继续浏览下面的相关文章,希望大家以后多多支持TOY模板网!