JVM
《JVM从入门到精通》导航 - VectorX - 博客园 (cnblogs.com)
进程和线程
进程
一个在内存中运行的应用程序。每个进程都有自己独立的一块内存空间,一个进程可以有多个线程,比如在Windows系统中,一个运行的xx.exe就是一个进程。相当于虚拟机
线程
进程中的一个执行任务(控制单元),负责当前进程中程序的执行。一个进程至少有一个线程,一个进程可以运行多个线程,多个线程可共享数据。
与进程不同的是同类的多个线程共享进程的堆和方法区资源,但每个线程有自己的程序计数器、虚拟机栈和本地方法栈,所以系统在产生一个线程,或是在各个线程之间作切换工作时,负担要比进程小得多,也正因为如此,线程也被称为轻量级进程。
Java 程序天生就是多线程程序
概念
1、JVM是Java虚拟机,是用来执行Java字节码(二进制的形式 )的虚拟机计算机。
2、JVM是运行在操作系统之上的,与硬件没有任何关系。
java虚拟机主要用来实现跨平台操作,一次编译,多处运行,编译之后的字节码文件和平台无关,需要在不同的操作系统之上安装一个对应版本的虚拟机(JVM)。
Java虚拟机不和包括Java在内的任何语言绑定,他只与class文件这种二进制文件格式所关联。无论使用何种语言进行软件开发,只要将源文件编译成正确的class文件,那么这种语言就可以在Java虚拟机上运行,可以说,统一而强大的Class文件结构,就是Java虚拟机的基石、桥梁
Java技术的核心就是java虚拟机,因为所有的Java程序都运行在java虚拟机内部
特点
一次编译,到处运行;自动内存管理;动垃圾回收功能
生命周期
虚拟机的启动
Java 虚拟机的启动是通过引导类加载器(bootstrap class loader)创建一个初始类(initial class)来完成的,这个类是由虚拟机的具体实现指定的。
我们想吃苹果(我们自定义的类),先种苹果树(虚拟机)。我们想执行一个程序,但是他的一些类机构和父类结构还有其他一些类都没有,我们就需要启动java虚拟机
虚拟机的执行
- 一个运行中的 Java 虚拟机有着一个清晰的任务:执行 Java 程序。
- 程序开始执行时他才运行,程序结束时他就停止。
- 执行一个所谓的 Java 程序的时候,真真正正在执行的是一个叫做 Java 虚拟机的进程。
虚拟机的退出
有如下的几种情况:
- 程序正常执行结束
- 程序在执行过程中遇到了异常或错误而异常终止
- 由于操作系统用现错误而导致 Java 虚拟机进程终止
- 某线程调用 Runtime 类或 system 类的 exit 方法,或 Runtime 类的 halt 方法,并且 Java 安全管理器也允许这次 exit 或 halt 操作。
- 除此之外,JNI(Java Native Interface)规范描述了用 JNI Invocation API 来加载或卸载 Java 虚拟机时,Java 虚拟机的退出情况。
内存结构
注意,JDK1.8以后 方法区 变为元空间或者永久代
Java 虚拟机定义了若干种程序运行期间会使用到的运行时数据区,其中有一些会随着虚拟机(进程)启动而创建,随着虚拟机(进程)退出而销毁。另外一些则是与线程一一对应的,这些与线程对应的数据区域会随着线程开始和结束而创建和销毁。
蓝色的区域:堆方法区是一个进程/虚拟机对应一份,红色区域,虚拟机栈,本地方法栈,程序计数器 一个线程对应一份,比如我们一个程序里面有五个线程,那么就有五组虚拟机栈,本地方法栈,程序技术器,这五个线程又共用方法区,堆空间
栈是运行时的单位,而堆是存储的单位
- 栈解决程序的运行问题,即程序如何执行,或者说如何处理数据。
- 堆解决的是数据存储的问题,即数据怎么放,放哪里
运行时数据区 | 是否存在 Error | 是否存在 GC |
---|---|---|
程序计数器 | 否 | 否 |
虚拟机栈 | 是(SOE) | 否 |
本地方法栈 | 是 | 否 |
方法区 | 是(OOM) | 是 |
堆 | 是 | 是 |
执行引擎
将字节码指令解释/编译成对应平台上的本地机器指令,简单来说就是将高级语言翻译为机器语言
解释器:当虚拟机启动的时候根据预定义的规范对字节码逐条翻译成对应的机器码(从pc寄存器中获取下一条要翻译的字节码)
JIT编译器:将源代码直接翻译为机器指令,一个字节码文件中的一些热点指令(经常执行,比如for循环里面的代码),缓存起来,不用每次执行都翻译为新的机器指令----效率更高
GC:垃圾回收
扩展
1.JVM在执行java代码的时候,可以使用JIT编译器也可以使用解释器,两个相辅相成。因此JAVA称为半编译半解释语言
2.编译器分为两个,一个前端编译器,也就是JAVAC(将.java文件编译成.class文件也就是字节码文件)。另一个就是后端编译器--JIT即时编译器,将字节码编译为机器码。
3.当虚拟机启动的时候,解释器可以首先发挥作用,逐条翻译,不必等待编译器(JIT即时编译器)全部编译成机器码在执行,这样可以省去许多不必要的编译时间,随着程序的不断运行,JIT即时编译器开始发挥作用,根据热点探测功能将有价值的字节码编译成本地机器码指令,以换取更高的执行效率,同时解释器可以作为编译器的备用方案
类加载器
- 类加载器子系统负责从文件系统或者网络中加载 Class 文件,class 文件在文件开头有特定的文件标识。
- ClassLoader 只负责 class 文件的加载,至于它是否可以运行,则由执行引擎 (Execution Engine) 决定。
- 加载的类信息存放于一块称为方法区的内存空间。除了类的信息外,方法区中还会存放运行时常量池信息,可能还包括字符串字面量和数字常量(这部分常量信息是 Class 文件中常量池部分的内存映射)
- 验证(Verify):
- 目的在子确保 Class 文件的字节流中包含信息符合当前虚拟机要求,保证待加载的class文件正确,不会危害虚拟机自身安全。
- 主要包括四种验证,文件格式验证,元数据验证,字节码验证,符号引用验证。
- 准备(Prepare):
- 为类变量 static 分配内存并且设置该类变量的默认初始值,即零值。final static 修饰的成员变量类型为ConstantValue(基本数据类型和String,被赋值时只能使用字面量而不是方法的形式)才会在准备阶段赋具体值(而不是默认值)
- 这里不包含用 final 修饰的 static,因为 final 在编译的时候就会分配了,准备阶段会显式初始化;
- final修饰成员变量(单独修饰不和static一起)在实例初始化时候被赋值,和非final修饰的成员变量赋值时机一样,只不过final修饰的不能多次赋值。
- 这里不会为实例变量分配初始化,类变量会分配在方法区中,而实例变量是会随着对象一起分配到 Java 堆中。
- 解析(Resolve):
- 将常量池内的符号引用转换为直接引用的过程。----类的名字解析为他在方法区内地址
- 事实上,解析操作往往会伴随着 JVM 在执行完初始化之后再执行。
- 符号引用就是一组符号来描述所引用的目标。符号引用的字面量形式明确定义在《java 虚拟机规范》的 Class 文件格式中。直接引用就是直接指向目标的指针、相对偏移量或一个间接定位到目标的句柄。
- 解析动作主要针对类或接口、字段、类方法、接口方法、方法类型等。对应常量池中的 CONSTANT_Class_info,CONSTANT_Fieldref_info、CONSTANT_Methodref_info 等。
扩展
首先final修饰成员变量(final单独修饰不和static一起使用)是在实例初始化的时候被赋值的。这个和非final修饰成员变量赋值时机没有什么不同,只不过final修饰的不能多次赋值罢了。
2、final static修饰的成员变量只有在其类型为ConstantValue时才会在准备阶段被赋予具体值(而不是类型的默认值)。
3、ConstantValue需要满足2个条件
- 类型为基本类型或者String
- 此类型被赋值时只能使用字面量而不是方法的形式
4、举例
- final static int a = 1;//准备阶段赋值-基本类型可以转化为ConstantValue,且使用的是字面量赋值。
- final static int a = getA();//初始化阶段赋值-基本类型可以转化为ConstantValue,但赋值不是使用字面量。
- final static String b = "abc";//准备阶段赋值-String可以转化为ConstantValue,且使用的是字面量赋值。
- final static String b= getB();//初始化阶段赋值-String可以转化为ConstantValue,但赋值不是使用字面量。
- final static Object c = new Object();//初始化阶阶段赋值-其他类型不可以转化为ConstantValue
- final static Object c = new Object();//初始化阶阶段赋值-其他类型不可以转化为ConstantValue
分类
JVM 支持两种类型的类加载器 。分别为引导类加载器(Bootstrap ClassLoader)和自定义类加载器(User-Defined ClassLoader/ ClassLoder)。
从概念上来讲,自定义类加载器一般指的是程序中由开发人员自定义的一类类加载器,但是 Java 虚拟机规范却没有这么定义,而是将所有派生于抽象类 ClassLoader 的类加载器都划分为自定义类加载器。
启动类加载器(引导类加载器,Bootstrap ClassLoader)
- 这个类加载使用 C/C++语言实现的,嵌套在 JVM 内部。
- 它用来加载 Java 的核心库(JAVA_HOME/jre/lib/rt.jar、resources.jar 或 sun.boot.class.path 路径下的内容),用于提供 JVM 自身需要的类
- 并不继承自 ava.lang.ClassLoader,没有父加载器。
- 加载扩展类和应用程序类加载器,并指定为他们的父类加载器。
- 出于安全考虑,Bootstrap 启动类加载器只加载包名为 java、javax、sun 等开头的类
扩展类加载器(Extension ClassLoader)
- Java 语言编写,由 sun.misc.Launcher$ExtClassLoader 实现。
- 派生于 ClassLoader 类
- 父类加载器为启动类加载器
- 从 java.ext.dirs 系统属性所指定的目录中加载类库,或从 JDK 的安装目录的 jre/lib/ext 子目录(扩展目录)下加载类库。如果用户创建的 JAR 放在此目录下,也会自动由扩展类加载器加载。
应用程序类加载器(系统类加载器,AppClassLoader)
- java 语言编写,由 sun.misc.LaunchersAppClassLoader 实现
- 派生于 ClassLoader 类
- 父类加载器为扩展类加载器
- 它负责加载环境变量 classpath 或系统属性 java.class.path 指定路径下的类库
- 该类加载是程序中默认的类加载器,一般来说,Java 应用的类都是由它来完成加载
- 通过 ClassLoader#getSystemclassLoader() 方法可以获取到该类加载器
双亲委派
避免类重复加载,防止核心API被篡改
同时为了避免类的加载重复,因为JVM区分不同类,不仅仅是根据类名,相同的class文件被不同的ClassLoder加载就是不同的两个类
工作原理
- 1)如果一个类加载器收到了类加载请求,它并不会自己先去加载,而是把这个请求委托给父类的加载器去执行;
- 2)如果父类加载器还存在其父类加载器,则进一步向上委托,依次递归,请求最终将到达顶层的启动类加载器;
- 3)如果父类加载器可以完成类加载任务,就成功返回,倘若父类加载器无法完成此加载任务,子加载器才会尝试自己去加载,这就是双亲委派模式。
沙箱安全机制
java安全模型的核心就是java沙箱,沙箱就是一个限制程序运行的环境。沙箱机制就是将java代码限定在虚拟机特定的运行范围内,并且严格限制代码对本地系统资源访问,通过这样的措施来保证对代码的有效隔离,防止对本地系统造成破坏。沙箱主要限制系统资源访问,系统资源包括 cpu,内存,文件系统,网络。不同级别的沙箱对这些资源访问的限制也可以不一样。
所有的java程序运行都可以指定沙箱,可以定制安全策略。
当前最近的安全机制实现,引入了域(Domain)的概念,虚拟机会把所有的代码加载到不同的系统域和应用域,系统域部分专门负责与关键资源进行交互,而各个应用域部分则通过系统域的部分代理来对各种需要的资源进行访问。虚拟机中不同的受保护域,对应不一样的权限。存在于不同域中的类文件就具有了当前域的全部权限。
通俗来说就是虚拟机把代码加载到拥有不同权限的域里,然后代码就拥有了该域的所有权限。这样就能控制不同代码拥有不同调用操作系统和本地资源的权限
jdk1.6
基本组件
组成沙箱的基本组件:
1 字节码校验器(bytecode verifier)
确保lava类文件遵循lava语言规范。这样可以帮助Java程序实现内存保护。但并不是所有的类文件都会经过字节码校验,比如核心类。
2 类装载器(class loader)
- 防止恶意代码去干涉善意的代码,比如:双亲委派机制
- 守护了被信任的类库边界;
- 将代码归入保护域,确定了代码的权限范围可以进行哪些资源操作
虚拟机为不同的类加载器载入的类提供不同的命名空间,命名空间由一系列唯一的名称组成,每一个被装载的类将有一个名字,这个命名空间是由java虚拟机为每一个类加载器维护的,他们互相之间甚至不可见。
类装载器采用的是双亲委派机制
1.从最内层JVM自带的类加载器开始加载,外层恶意同名类得不到加载从而无法调用
2.由于严格通过包来区分了访问域,外层恶意的类通过内置代码也无法获得权限访问到内部类,破坏代码就自然无法生效
3 存取控制器(access controller)
存取控制器可以控制核心API对操作系统的存取权限,用户可以设定控制策略。
4 安全管理器(security manager)
安全管理器主要是核心API和操作系统之间的主要接口。比如实现权限控制,比存取控制器优先级高。
5 安全软件包(security package)
java.security下的类和扩展包下的类,允许用户为应用增加所需要安全特性:安全提供者、消息摘要、数字签名keytools、加密、鉴别。
程序计数器
PC Register,程序计数寄存器,简称为程序计数器,pc寄存器
是物理寄存器的抽象实现
用来记录待执行的下一条指令的地址
程序控制流的指示器,循环,if,else,异常处理,线程恢复等都依赖它来完成
解释器工作时就是通过它来获取下一条需要执行的字节码指令的
它是唯一一个在JVM规范中没有规定任何OutOfMEmoryError(内存溢出)情况的区域
它是一块很小的内存空间,几乎可以忽略不记。也是运行速度最快的存储区域。
在 JVM 规范中,每个线程都有它自己的程序计数器,是线程私有的,生命周期与线程的生命周期保持一致。
任何时间一个线程都只有一个方法在执行,也就是所谓的当前方法。程序计数器会存储当前线程正在执行的 Java 方法的 JVM 指令地址;或者,如果是在执行 native 方法,则是未指定值(undefined)。
常见问题
使用 PC 寄存器存储字节码指令地址有什么用呢?为什么使用 PC 寄存器记录当前线程的执行地址呢?
因为 CPU 需要不停的切换各个线程,这时候切换回来以后,就得知道接着从哪开始继续执行。
JVM 的字节码解释器就需要通过改变 PC 寄存器的值来明确下一条应该执行什么样的字节码指令。
PC 寄存器为什么被设定为私有的?
我们都知道所谓的多线程在一个特定的时间段内只会执行其中某一个线程的方法,CPU 会不停地做任务切换,这样必然导致经常中断或恢复,如何保证分毫无差呢?为了能够准确地记录各个线程正在执行的当前字节码指令地址,最好的办法自然是为每一个线程都分配一个 PC 寄存器,这样一来各个线程之间便可以进行独立计算,从而不会出现相互干扰的情况。
由于 CPU 时间片轮限制,众多线程在并发执行过程中,任何一个确定的时刻,一个处理器或者多核处理器中的一个内核,只会执行某个线程中的一条指令。
这样必然导致经常中断或恢复,如何保证分毫无差呢?每个线程在创建后,都会产生自己的程序计数器和栈帧,程序计数器在各个线程之间互不影响。
CPU 时间片
CPU 时间片即 CPU 分配给各个程序的时间,每个线程被分配一个时间段,称作它的时间片。
在宏观上:俄们可以同时打开多个应用程序,每个程序并行不悖,同时运行。
但在微观上:由于只有一个 CPU,一次只能处理程序要求的一部分,如何处理公平,一种方法就是引入时间片,每个程序轮流执行。
虚拟机栈
Java 虚拟机栈(Java Virtual Machine Stack),早期也叫 Java 栈。每个线程在创建时都会创建一个虚拟机栈,其内部保存一个个的栈帧(Stack Frame),对应着一次次的 Java 方法调用,是线程私有的。
生命周期和线程一致
主管 Java 程序的运行,保存方法的局部变量(8种基本数据类型,对象的引用地址)、部分结果,并参与方法的调用和返回。
栈是一种快速有效的分配存储方式,访问速度仅次于程序计数器。
JVM 直接对 Java 栈的操作只有两个:
- 每个方法执行,伴随着进栈(入栈、压栈)
- 执行结束后的出栈工作
对于栈来说不存在垃圾回收问题(栈存在溢出的情况)
异常
Java 虚拟机规范允许Java 栈的大小是动态的或者是固定不变的。
-
如果采用固定大小的 Java 虚拟机栈,那每一个线程的 Java 虚拟机栈容量可以在线程创建的时候独立选定。如果线程请求分配的栈容量超过 Java 虚拟机栈允许的最大容量,Java 虚拟机将会抛出一个StackOverflowError 异常。
-
如果 Java 虚拟机栈可以动态扩展,并且在尝试扩展的时候无法申请到足够的内存,或者在创建新的线程时没有足够的内存去创建对应的虚拟机栈,那 Java 虚拟机将会抛出一个 OutOfMemoryError (OOM)异常。--- JVM中栈不可以动态
public static void main(String[] args) { test(); } public static void test() { test(); } //抛出异常:Exception in thread"main"java.lang.StackoverflowError //程序不断的进行递归调用,而且没有退出条件,就会导致不断地进行压栈。 // 类的循环引用public class test2 { public static void main(String[] args) throws JsonProcessingException { Grade grade = new Grade(); grade.setname("1"); Student s1 = new Student(); s1.setName("zhangsan"); s1.setGrade(grade); Student s2 = new Student(); s2.setName("lisi"); s2.setGrade(grade); grade.setStudents(Arrays.asList(s1,s2)); //转换为JSON字符串 死循环 //{grade,students:【{name,grade,students:【....】}】} ObjectMapper objectMapper = new ObjectMapper(); System.out.println(objectMapper.writeValueAsString(grade)); } } //一个年级类,包含年级名称,和对应学生 @Data class Grade{ private String name; private List<Student> students; } // 一个学生类,包含学生名称和对应班级 @Data class Student{ private String name; @JsonIgnore //转换时忽略这个属性,避免发生循环转换 private Grade grade; } //抛出异常:Exception in thread"main"java.lang.StackoverflowError
栈内存大小
我们可以使用参数 -Xss 选项来设置线程的最大栈空间,栈的大小直接决定了函数调用的最大可达深度
【IDEA设置内存大小】_duke_wzd的博客-CSDN博客
存储单位
每个线程都有自己的栈,栈中的数据都是以栈帧(Stack Frame)的格式存在。
在这个线程上正在执行的每个方法都各自对应一个栈帧(Stack Frame)。
栈帧是一个内存区块,是一个数据集,维系着方法执行过程中的各种数据信息。
栈运行原理
JVM 直接对 Java 栈的操作只有两个,就是对栈帧的压栈和出栈,遵循“先进后出”/“后进先出”原则。
在一条活动线程中,一个时间点上,只会有一个活动的栈帧。即只有当前正在执行的方法的栈帧(栈顶栈帧)是有效的,这个栈帧被称为当前栈帧(Current Frame),与当前栈帧相对应的方法就是当前方法(Current Method),定义这个方法的类就是当前类(Current Class)。
执行引擎运行的所有字节码指令只针对当前栈帧进行操作。
如果在该方法中调用了其他方法,对应的新的栈帧会被创建出来,放在栈的顶端,成为新的当前帧。
不同线程中所包含的栈帧是不允许存在相互引用的,即不可能在一个栈帧之中引用另外一个线程的栈帧。
如果当前方法调用了其他方法,方法返回之际,当前栈帧会传回此方法的执行结果给前一个栈帧,接着,虚拟机会丢弃当前栈帧,使得前一个栈帧重新成为当前栈帧。
Java 方法有两种返回函数的方式,一种是正常的函数返回,使用 return 指令;另外一种是抛出异常(没有处理)。不管使用哪种方式,都会导致栈帧被弹出。
public class test2 { public static void main(String[] args) { test2 test2 = new test2(); test2.methodA(); System.out.println("mian()正常结束 ----"); //异常结束 } public void methodA() { System.out.println("methodA开始执行 ----"); methodB(); System.out.println("methodA执行结束 ----"); System.out.println(10/0); } public int methodB() { System.out.println("methodB开始执行 ----"); int i = 10; System.out.println("methodB执行结束 ----"); return i; } } methodA开始执行 ---- methodB开始执行 ---- methodB执行结束 ---- methodA执行结束 ---- Exception in thread "main" java.lang.ArithmeticException: / by zero
栈帧的内部结构
每个栈帧中存储着:
- 局部变量表(Local Variables)
- 操作数栈(operand Stack)(或表达式栈)
- 动态链接(DynamicLinking)(或指向运行时常量池的方法引用)
- 方法返回地址(Return Address)(或方法正常退出或者异常退出的定义)
- 一些附加信息
并行每个线程下的栈都是私有的,因此每个线程都有自己各自的栈,并且每个栈里面都有很多栈帧,栈帧的大小主要由局部变量表 和 操作数栈决定的
局部变量表(Local Variables)
局部变量表也被称之为局部变量数组或本地变量表
-
定义为一个数字数组,主要用于存储方法参数和定义在方法体内的局部变量,这些数据类型包括各类基本数据类型、对象引用(reference),以及 returnAddress(返回值) 类型。
-
由于局部变量表是建立在线程的栈上,是线程的私有数据,因此不存在数据安全问题
-
局部变量表所需的容量大小是在编译期确定下来的,并保存在方法的 Code 属性的 maximum local variables 数据项中。在方法运行期间是不会改变局部变量表的大小的。
-
局部变量表中的变量只在当前方法调用中有效。在方法执行时,虚拟机通过使用局部变量表完成参数值到参数变量列表的传递过程。当方法调用结束后,随着方法栈帧的销毁,局部变量表也会随之销毁。
-
局部变量表,最基本的存储单元是 Slot(变量槽),记录局部变量在局部变量表中的位置
-
在局部变量表里,32 位以内的类型只占用一个 slot(包括 returnAddress 类型),64 位的类型(long 和 double)占用两个 slot。
-
byte、short、char 在存储前被转换为 int,boolean 也被转换为 int,0 表示 false,非 0 表示 true。
-
如果当前帧是由构造方法或者实例方法创建的,那么该对象引用 this 将会存放在 index 为 0 的 slot 处,其余的参数按照参数表顺序继续排列。
补充说明
在栈帧中,与性能调优关系最为密切的部分就是前面提到的局部变量表。在方法执行时,虚拟机使用局部变量表完成方法的传递。
局部变量表中的变量也是重要的垃圾回收根节点,只要被局部变量表中直接或间接引用的对象都不会被回收。
操作数栈(Operand Stack)
操作数栈,主要用于保存计算过程的中间结果,同时作为计算过程中变量临时的存储空间。
我们说 Java 虚拟机的解释引擎是基于栈的执行引擎,其中的栈指的就是操作数栈。
在方法执行过程中,根据字节码指令,往栈中写入数据或提取数据,即入栈(push)和 出栈(pop)
- 某些字节码指令将值压入操作数栈,其余的字节码指令将操作数取出栈。使用它们后再把结果压入栈
- 比如:执行复制、交换、求和等操作
代码举例 public void testAddOperation(){ byte i = 15; int j = 8; int k = i + j; }
使用 javap 命令反编译 class 文件:javap -v 类名.class
public void testAddOperation(); Code: 0: bipush 15 2: istore_1 3: bipush 8 5: istore_2 6:iload_1 7:iload_2 8:iadd 9:istore_3 10:return
具体细节
<JVM上篇:内存与垃圾回收篇>04-虚拟机栈 - VectorX - 博客园 (cnblogs.com)
问题辨析
垃圾回收是否涉及栈内存
- 不涉及,栈内存只不过是一次次方法调用所产生的栈帧内存,而栈帧内存会在每次方法调用之后就被弹出栈,也就是自动的回收掉,所以不需要垃圾回收来管理内存
栈内存分配
- -Xss size 设计栈内存
- Linux,orade,macOS 默认大小为 1024KB,windows,根据系统内存设置
- 并不是越大越好,大了只是可以进行更多次的递归调用,而不会增快运行效率
方法内的局部变量是否线程安全
- 方法中的局部变量如果没有逃离方法的作用范围访问,就是线程安全的
- 如果是局部变量引用了对象并逃离方法的作用范围访问,就需要考虑线程安全(作为形参,返回值)
栈溢出
- 产生条件:递归调用(类的循环引用),栈帧过大(不太容易出现)
- cpu占用过高
- 打开xshell软件(用来连接虚拟机的可视化软件)
- top --- 可以监测到后台进程对cpu的使用情况,找到有问题的进程编号
-
-
- ps H -eo 你要查看的内容(pid,tid,%cpu) l grep 进程id (用ps命令进一步定位是哪一个线程引发的cpu过高)
- jstack 进程id
- 可以根据线程id找到有问题的线程,进一步定位到问题的源码行数
- 注意 jstack 下面的线程id是16进制的,ps 输出的线程编号是10进制的
-
2.迟迟得不到结果 --- 发送死锁
本地方法栈
本地方法( Native Method),在java中定义的方法,但由其他语言实现。
Java 虚拟机栈于管理 Java 方法的调用,而本地方法栈用于管理本地方法的调用。
本地方法栈,也是线程私有的,可能存在 SOF和OOM异常。
当某个线程调用一个本地方法时,它就进入了一个全新的并且不再受虚拟机限制的世界。它和虚拟机拥有同样的权限。
- 本地方法可以通过本地方法接口来访问虚拟机内部的运行时数据区。
- 它甚至可以直接使用本地处理器中的寄存器
- 直接从本地内存的堆中分配任意数量的内存。
并不是所有的 JVM 都支持本地方法。因为 Java 虚拟机规范并没有明确要求本地方法栈的使用语言、具体实现方式、数据结构等。如果 JVM 产品不打算支持 native 方法,也可以无需实现本地方法栈。
在 Hotspot JVM 中,直接将本地方法栈和虚拟机栈合二为一
//native:凡是带了native关键字的,说明java的作用范围达不到,回去调用底层C语言库 //进入本地方法栈,调用本地方法本地接口 JNI // JNI作用:扩展java的使用,融合不同的编程语言为Java所用 private native void start0();
堆 Heap
JVM规范中规定所有的对象实例(new出来的)和数组都应该存放在堆中,在执行字节码指令时,会把创建的对象存入堆中,对象的引用地址存入到虚拟机的栈帧中,不过方法执行完之后,刚刚创建的对象不会立马回收,而是等JVM后台执行GC后,对象才会被回收
堆空间内部结构
Java 7 及之前堆内存逻辑上分为三部分:新生代+老年代+永久代
- Young Generation Space 新生代 Young/New 又被划分为 Eden 区和 Survivor 区
- Tenure generation space 老年代 Old/Tenure
- Permanent Space 永久区 Perm
Java 8 及之后堆内存逻辑上分为三部分:新生代+老年代+元空间
- Meta Space 元空间 Meta
内存大小
Java 堆区用于存储 Java 对象实例,那么堆的大小在 JVM 启动时就已经设定好了,大家可以通过选项"-Xmx"和"-Xms"来进行设置。
- “-Xms"用于表示堆区的起始内存,等价于
-XX:InitialHeapSize
- “-Xmx"则用于表示堆区的最大内存,等价于
-XX:MaxHeapSize
一旦堆区中的内存大小超过“-Xmx"所指定的最大内存时,将会抛出 OutOfMemoryError 异常。
通常会将-Xms 和-Xmx 两个参数配置相同的值,其目的是为了能够在 java 垃圾回收机制清理完堆区后不需要重新计算堆区的大小,从而提高性能。
默认情况下
- 初始内存大小:物理电脑内存大小 / 64
- 最大内存大小:物理电脑内存大小 / 4
年轻代与老年代
存储在 JVM 中的 Java 对象可以被划分为两类:
- 一类是生命周期较短的瞬时对象,这类对象的创建和消亡都非常迅速
- 另外一类对象的生命周期却非常长,在某些极端的情况下还能够与 JVM 的生命周期保持一致
其中年轻代又可以划分为 Eden 空间、两个幸存者空间,Survivor0 空间和 Survivor1 空间(有时也叫做 from 区、to 区)
下面这参数开发中一般不会调:
配置新生代与老年代在堆结构的占比。2:1
- 默认
-XX:NewRatio=2
,表示新生代占 1,老年代占 2,新生代占整个堆的 1/3 - 可以修改
-XX:NewRatio=4
,表示新生代占 1,老年代占 4,新生代占整个堆的 1/5
在 HotSpot 中,Eden 空间和另外两个 survivor 空间缺省所占的比例是 8:1:1
当然开发人员可以通过选项“-xx:SurvivorRatio
”调整这个空间比例。比如-xx:SurvivorRatio=8
几乎所有的 Java 对象都是在 Eden 区被 new 出来的。绝大部分的 Java 对象的销毁都在新生代进行了。
- IBM 公司的专门研究表明,新生代中 80%的对象都是“朝生夕死”的。
可以使用选项"-Xmn
"设置新生代最大内存大小,这个参数一般使用默认值就可以了。
对象分配过程
1、绝大多数刚刚被创建的对象会存放在伊甸园空间(Eden)。
2、当伊甸园的空间填满时,程序又需要创建对象,JVM 的垃圾回收器将对伊甸园区进行垃圾回收(MinorGC),将伊甸园区中的不再被其他对象所引用的对象进行销毁。再加载新的对象放到伊甸园区
3、然后将伊甸园中的剩余对象移动到幸存者 0 区。
4、如果再次触发垃圾回收,此时上次幸存下来的放到幸存者 0 区的,如果没有回收,就会放到幸存者 1 区。
5、如果再次经历垃圾回收,此时会重新放回幸存者 0 区,接着再去幸存者 1 区。
6、在以上步骤中重复N次(默认15)依然存活的对象,就会被移动到老年代。
可以设置参数:-Xx:MaxTenuringThreshold= N
进行设置
7、在老年代,相对悠闲。当养老区内存不足时,再次触发 GC:Major GC,进行养老区的内存清理
8、若老年代执行了 Major GC 之后,发现依然无法进行对象的保存,就会产生 OOM 异常。
注意:
如果我们创建了一个比较大的对象,幸存者空间存内存不够,存放不下,则直接进入老年代,同样伊甸园区放不下也会直接进入老年代。
如果 survivor 区中相同年龄的所有对象大小的总和大于 Survivor 空间的一半,年龄大于或等于该年龄的对象可以直接进入老年代,无须等到MaxTenuringThreshold
中要求的年龄。
两个Survivor区是对称的,没先后关系,所以同一个Survivor区中可能同时存在从Eden区复制过来对象,和从另一个 Survivor区复制过来的对象;而复制到年老区的只有从前一个Survivor区(相对的)过来的对象。
特 殊的情况下,根据程序需要,Survivor区是可以配置为多个的(多于两个)
总结
- 针对幸存者 s0,s1 区的总结:复制之后有交换,谁空谁是 to,也就是说to总为空
- 关于垃圾回收:频繁在新生区收集,很少在老年代收集,几乎不再永久代和元空间进行收集
垃圾回收
JVM 在进行 GC 时,并非每次都对上面三个内存区域一起回收的,大部分时候回收的都是指新生代。
- Minor GC / Young GC:支持新生代的垃圾回收
- Major GC / Old GC:支持老年代的垃圾回收,目前,只有 CMS垃圾收集器会单独对老年代进行垃圾回收,其他的垃圾回收器基本都是整堆回收的时候对老年代进行垃圾回收
- Full GC 整堆回收:收集整个 java 堆和方法区的垃圾收集。
- 混合收集(MixedGC):收集整个新生代以及部分老年代的垃圾收集。目前,只有 G1 GC 会有这种行为
问题辨析
1.启动程序,打开命令窗口
2.jps 工具
查看当前系统有哪些java进程
3.jmap 工具
查看堆内存占用情况 -- 某一时刻
4.jconsole工具
图形界面的,多功能检测工具,可以连续监测
5.jvirsualvm
垃圾回收之后,内存占用依旧很高
命令窗口启动 jvirsualvm命令
永久代/元空间
存储的是被虚拟机加载的类信息和静态变量,常量,即时编译器JIT编译之后的代码信息
JDK6、JDK7 时,方法区 就是 PermGen
(永久代)。
JDK8 时,方法区就是 Metaspace
(元空间)
Metaspace(元空间)和 PermGen(永久代)类似,都是对 JVM规范中方法区的一种落地实现。
不过元空间与永久代之间最大的区别在于:元空间并不在虚拟机中,而是使用本地内存。
转换原因:
- 字符串存在永久代中,容易出现性能问题和内存溢出。
- 类及方法的信息等比较难确定其大小,因此对于永久代的大小指定比较困难,太小容易出现永久代溢出,太大则容易导致老年代溢出。
- 永久代会为 GC 带来不必要的复杂度,并且回收效率偏低。
元空间特点
- 每个加载器都有专门的存储空间
- 不会单独回收某个类
- 元空间里的对象的位置是固定的
- 如果发现某个加载器不再存货了,会把相关的空间整个回收
方法区
栈、堆、方法区的交互关系
方法区的理解
方法区看作是一块独立于 Java 堆的内存空间,方法区是JVM的一种规范,元空间和持久代是HotSpot
的具体实现
JDK7之前方法区叫做持久代,JDK8之后元空间取代持久代,两者的区别在于持久代在堆中,元空间存放在本地内存
它用于存储已被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等。
内存溢出
内存溢出 Out Of Memory(man mo rui),是指JVM可用内存不足。JVM运行需要使用的内存超过最大可用值。
常见的有
栈溢出 ---- 死递归
public class test2 { public static void main(String[] args) { test2 test2 = new test2(); test2.methodA(); } public void methodA() { methodB(); } public void methodB() { methodA(); } } Exception in thread "main" java.lang.StackOverflowError
堆溢出
内存不够时会先进行垃圾回收,垃圾回收之后还是没有效果,内存还是不够,就会报OOM异常
//JVM args: -Xms30m -Xmx30m //JVM 中配置一个参数: -XX:+HeapDumpOnOutOfMemoryError,一但JVM即将发生OOM的时候,导出一个Dump日志,方便事后进行分析 public class test2 { public static void main(String[] args) { String[] s = new String[35*1000*1000];// 设置一个35m的数组 } } Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
方法区溢出
本机直接内存溢出
内存泄露
内存泄露 Memory Leak,是指本来无用的对象却继续占用内存,没有在恰当的时机释放占用的内存,不使用的内存却没有释放。
读取文件后流要进行及时的关闭以及对数据库连接的释放
每一个请求进来,或者每一次操作处理,都分配了内存,却有一部分不能回收(或未释放),那么随着处理的请求越来越多,内存泄露也就越来越严重。
如果存在严重的内存泄露问题,随着时间的推迟,则必然会引起1内存溢出。
内存泄露一般是资源管理问题和程序BUG,内存溢出则是内存空间不足和内存泄露的最终结果。
垃圾回收
垃圾是指在运行程序中没有任何指针指向的对象,这个对象就是需要被回收的垃圾。
如果不及时对内存中的垃圾进行清理,那么,这些垃圾对象所占的内存空间会一直保留到应用程序的结束,被保留的空间无法被其它对象使用,甚至可能导致内存溢出。
触发机制
年轻代 GC(Minor GC)触发机制
-
当年轻代空间不足时,就会触发 MinorGC,这里的年轻代满指的是 Eden 代满,Survivor 满不会引发 GC。(每次 Minor GC 会清理年轻代的内存。)
-
因为Java 对象大多都具备朝生夕灭的特性.,所以 Minor GC 非常频繁,一般回收速度也比较快。这一定义既清晰又易于理解。
-
Minor GC 会引发 STW( stop the world 不能提供任何服务),暂停其它用户的线程,等垃圾回收结束,用户线程才恢复运行
老年代 GC(Major GC / Full GC)触发机制
-
指发生在老年代的 GC,对象从老年代消失时,我们说 “Major GC” 或 “Full GC” 发生了
-
出现了 Major Gc,经常会伴随至少一次的 Minor GC(但非绝对的,在 Paralle1 Scavenge 收集器的收集策略里就有直接进行 MajorGC 的策略选择过程)
- 也就是在老年代空间不足时,会先尝试触发 Minor Gc。如果之后空间还不足,则触发 Major GC
-
Major GC 的速度一般会比 Minor GC 慢 10 倍以上,STW 的时间更长
-
如果 Major GC 后,内存还不足,就报 OOM 了
Full GC 触发机制(后面细讲):
触发 Full GC 执行的情况有如下五种:
- 调用 System.gc()时,系统建议执行 Full GC,但是不必然执行
- 老年代空间不足
- 方法区空间不足
- 通过 Minor GC 后进入老年代的平均大小大于老年代的可用内存
- 由 Eden 区、survivor space0(From Space)区向 survivor space1(To Space)区复制时,对象大小大于 To Space 可用内存,则把该对象转存到老年代,且老年代的可用内存小于该对象大小
说明:Full GC 是开发或调优中尽量要避免的。这样暂时时间会短一些
垃圾回收算法
垃圾标记阶段
也就是找到JVM(主要是堆)中有哪些是垃圾(死亡)对象,当一个对象已经不再被任何的存活对象继续引用时,就可以宣判为已经死亡。
判断对象存活一般有两种方式:引用计数算法和可达性分析算法。
引用计数法
对每个对象保存一个整型的引用计数器属性。用于记录对象被引用的情况。
对于一个对象 A,只要有任何一个对象引用了 A,则 A 的引用计数器就加 1;当引用失效时,引用计数器就减 1。只要对象 A 的引用计数器的值为 0,即表示对象 A 不可能再被使用,可进行回收。
优点:实现简单,垃圾对象便于辨识;判定效率高,回收没有延迟性。
缺点:
- 它需要单独的字段存储计数器,这样的做法增加了存储空间的开销。
- 每次赋值都需要更新计数器,伴随着加法和减法操作,这增加了时间开销。
- 引用计数器有一个严重的问题,即无法处理循环引用的情况。这是一条致命缺陷,导致在 Java 的垃圾回收器中没有使用这类算法。
可达性分析算法
- 可达性分析算法是以根对象集合(GCRoots)为起始点,按照从上至下的方式搜索被根对象集合所连接的目标对象是否可达。
- 使用可达性分析算法后,内存中的存活对象都会被根对象集合直接或间接连接着,搜索所走过的路径称为引用链(Reference Chain)
- 如果目标对象没有任何引用链相连,则是不可达的,就意味着该对象己经死亡,可以标记为垃圾对象。
- 在可达性分析算法中,只有能够被根对象集合直接或者间接连接的对象才是存活对象。
GC Roots
GC Roots是一组引用,包括
- 线程中虚拟机栈中正在执行的方法中的方法参数,局部变量所对应的对象引用
- 线程中本地方法栈中正在执行的方法中的方法参数,局部变量所对应的对象引用
- 方法区中保存的类信息中的静态属性所对应的对象引用
- 方法区中保存的类信息中的常量属性所对应的对象引用
- 等等
算法
标记-清除 Mark-Sweep算法
是一种非常基础和常见的垃圾收集算法,针对某块内存空间,比如新生代,老年代。如果可用内存不足,就会STW,暂停用户线程执行,进行垃圾回收
标记阶段:从根节点(GC Roots)开始遍历,找到可达对象,并在对象头中进行记录
清除阶段: 对堆内存从头到尾进行线性的遍历,如果发现某个对象在其 Header 中没有标记为可达对象,则将其回收
缺点:
导致内存碎片。空间碎片太多会导致当程序需要为较大对象分配内存时无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作。
复制(Copying)算法
一般用于新生代
将活着的内存空间分为两块,每次只使用其中一块,在垃圾回收时将正在使用的内存中的存活对象复制到未被使用的内存块中,之后清除正在使用的内存块中的所有对象,交换两个内存的角色,最后完成垃圾回收
优点
- 没有标记和清除过程,实现简单,运行高效
- 复制过去以后保证空间的连续性,不会出现“碎片”问题。
缺点
- 需要更多的内存,始终有一半的内存空闲
- 对象复制后,对象存放的内存地址发生了变化,需要额外的时间修改栈帧中记录的引用地址
- 如果可达对象比较多,垃圾对象比较少,那么效率就会很低,所以垃圾对象多的情况下,复制算法比较合适
标记-压缩(或标记-整理、Mark-Compact)算法
适用于老年代
-
第一阶段和标记清除算法一样,从根节点开始标记所有被引用对象
-
第二阶段将所有的存活对象压缩到内存的一端,按顺序排放。
-
之后,清理边界外所有的空间。
分代收集算法
是一种理念,不是一种算法
不同的对象的生命周期是不一样的。因此,不同生命周期的对象可以采取不同的收集方式,以便提高回收效率。一般是把 Java 堆分为新生代和老年代,这样就可以根据各个年代的特点使用不同的回收算法,以提高垃圾回收的效率。
目前几乎所有的 GC 都采用分代收集算法执行垃圾回收的。
在 HotSpot 中,基于分代的概念,GC 所使用的内存回收算法必须结合年轻代和老年代各自的特点。
年轻代(Young Gen)
年轻代特点:区域相对老年代较小,对象生命周期短、存活率低,回收频繁。
这种情况复制算法的回收整理,速度是最快的。复制算法的效率只和当前存活对象大小有关,因此很适用于年轻代的回收。而复制算法内存利用率不高的问题,通过 hotspot 中的两个 survivor 的设计得到缓解。
老年代(Tenured Gen)
老年代特点:区域较大,对象生命周期长、存活率高,回收不及年轻代频繁。
这种情况存在大量存活率高的对象,复制算法明显变得不合适。一般是由标记-清除或者是标记-清除与标记-整理的混合实现。
- CMS 是基于 Mark-Sweep(标记清除) 实现的
- Serial Old 垃圾回收器采用的是标记整理算法
垃圾回收器
截止 JDK1.8,一共有 7 款不同的垃圾收集器。每一款的垃圾收集器都有不同的特点,在具体使用的时候,需要根据具体的情况选用不同的垃圾收集器。
垃圾收集器 | 分类 | 作用位置 | 使用算法 | 特点 | 适用场景 |
---|---|---|---|---|---|
Serial | 串行运行 | 作用于新生代 | 复制算法 | 响应速度优先 | 适用于单 CPU 环境下的 client 模式 |
ParNew | 并行运行 | 作用于新生代 | 复制算法 | 响应速度优先 | 多 CPU 环境 Server 模式下与 CMS 配合使用 |
Parallel | 并行运行 | 作用于新生代 | 复制算法 | 吞吐量优先 | 适用于后台运算而不需要太多交互的场景 |
Serial Old | 串行运行 | 作用于老年代 | 标记-压缩算法 | 响应速度优先 | 适用于单 CPU 环境下的 Client 模式 |
Parallel Old | 并行运行 | 作用于老年代 | 标记-压缩算法 | 吞吐量优先 | 适用于后台运算而不需要太多交互的场景 |
CMS | 并发运行 | 作用于老年代 | 标记-清除算法 | 响应速度优先 | 适用于互联网或 B/S 业务 |
G1 | 并发、并行运行 | 作用于新生代、老年代 | 标记-压缩算法、复制算法 | 响应速度优先 | 面向服务端应用 |
GC 发展阶段:Serial => Parallel(并行)=> CMS(并发)=> G1 => ZGC
垃圾收集器没有在规范中进行过多的规定,可以由不同的厂商、不同版本的 JVM 来实现。
由于 JDK 的版本处于高速迭代过程中,因此 Java 发展至今已经衍生了众多的 GC 版本。
从不同角度分析垃圾收集器,可以将 GC 分为不同的类型。
分类
串行 | 吞吐量优先 | 响应时间优先 | ||||
单线程 | 多线程 | 多线程 | ||||
堆内存比较小,适合个人电脑 | 堆内存较大,多核cpu | 堆内存较大,多核cpu | ||||
单位时间内STW时间最短 | 单次STW时间最短 | |||||
serial,serialOld | parallel parallelOld | CMS G1 | ||||
复制 标记-压缩 | 复制 标记-压缩 |
|
Serial串行收集器
最基本,最悠久的
为单线程环境设计且只使用一个线程进行垃圾回收,它在进行垃圾收集时,必须暂停其他所有的工作线程,直到它收集完成。 不适合服务器环境。
只有一个线程执行GC,只适用于几十兆内存空间
Serial 收集器作为 HotSpot 中 client 模式下的默认新生代垃圾收集器。
Serial 收集器采用复制算法、串行回收和"stop-the-World"机制的方式执行内存回收。
除了年轻代之外,Serial 收集器还提供用于执行老年代垃圾收集的 Serial Old 收集器。Serial Old 收集器同样也采用了串行回收和"Stop the World"机制,只不过内存回收算法使用的是标记-压缩算法。
- Serial old 是运行在 Client 模式下默认的老年代的垃圾回收器
- Serial 0ld 在 Server 模式下主要有两个用途:① 与新生代的 Parallel scavenge 配合使用 ② 作为老年代 CMS 收集器的后备垃圾收集方案
Parallel并行收集器
ParallelScavenge 收集器的目标则是达到一个可控制的吞吐量(Throughput),它也被称为吞吐量优先的垃圾收集器。
高吞吐量则可以高效率地利用 CPU 时间,尽快完成程序的运算任务,主要适合在后台运算而不需要太多交互的任务。因此,常见在服务器环境中使用。例如,那些执行批量处理、订单处理、工资支付、科学计算的应用程序。
Parallel 收集器在 JDK1.6 时提供了用于执行老年代垃圾收集的 Parallel Old 收集器,用来代替老年代的 Serial Old 收集器。
Parallel Old 收集器采用了标记-压缩算法,但同样也是基于并行回收和"Stop-the-World"机制。
在程序吞吐量优先的应用场景中,Parallel 收集器和 Parallel Old 收集器的组合,在 Server 模式下的内存回收性能很不错。在 Java8 中,默认是此垃圾收集器。
CMS并发收集器
核心思想就是将STW打散,让一部分GC线程和用户线程交替执行,标记清除算法
用户线程和垃圾收集线程同时执行(不一定是并行,可能交替执行),不需要停顿用户线程。互联网公司多用它,适用对响应时间有要求的场景
分为四个阶段
- 初始标记(Initial-Mark)阶段:在这个阶段中,程序中所有的工作线程都将会因为“Stop-the-World”机制而出现短暂的暂停,这个阶段的主要任务仅仅只是标记出 GCRoots 能直接关联到的对象。一旦标记完成之后就会恢复之前被暂停的所有应用线程。由于直接关联对象比较小,所以这里的速度非常快。
- 并发标记(Concurrent-Mark)阶段:从 GC Roots 的直接关联对象开始遍历整个对象的过程,这个过程耗时较长但是不需要停顿用户线程,可以与垃圾收集线程一起并发运行。
- 重新标记(Remark)阶段:由于在并发标记阶段中,程序的工作线程会和垃圾收集线程同时运行或者交叉运行,因此为了修正并发标记期间,因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间通常会比初始标记阶段稍长一些,但也远比并发标记阶段的时间短。
- 并发清除(Concurrent-Sweep)阶段:此阶段清理删除掉标记阶段判断的已经死亡的对象,释放内存空间。由于不需要移动存活对象,所以这个阶段也是可以与用户线程同时并发的
G1 Garbage First 垃圾优先
他的内存模型是实际不分代,但是逻辑上是分代的。在内存模型中,对于堆内存就不再分老年代和新生代,而是划分成一个一个的小内存块,叫做Region。 使用不同的 Region 来表示 Eden、幸存者 0 区,幸存者 1 区,老年代等。
堆内存会分为2048个region,每个region的大小等于堆内存除以2048,Humongous区是专门用来存放大对象的(如果一个对象大小超过了一个region 的50%,那么就是大对象)
GC分为四个阶段:
第一:初始标记标记出GCRoot直接引用的对象。STW、
第二:标记Region,通过RSet标记出上一个阶段标记的Region引用到的Old区Region.
第三:并发标记阶段:跟CMS的步骤是差不多的。只是遍历的范围不再是整个Old区,而只需要遍历第二步标记出来的Region。
第四:重新标记:跟CM S中的重新标记过程是差不多的。
第五:垃圾清理:与CMS不同的是,G1可以采用拷贝算法,直接将整个Region中的对象拷贝到另一-个Region。 而这个阶段,G1只选择垃圾较多的Region来清理,并不是完全清理。
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 无需6万激活码!GitHub神秘组织3小时极速复刻Manus,手把手教你使用OpenManus搭建本
· Manus爆火,是硬核还是营销?
· 终于写完轮子一部分:tcp代理 了,记录一下
· 别再用vector<bool>了!Google高级工程师:这可能是STL最大的设计失误
· 单元测试从入门到精通