提示:文章写完后,目录可以自动生成,如何生成可参考右边的帮助文档
不足和错误的地方欢迎指正
子系统Class Louder,类加载器
比如把java.lang.object加载到运行时数据区的方法区
组件Running data area,运行时方法(数据)区,也叫JVM内存
类和对象加载到这里
子系统Excutioon engine,执行引擎
将编译后的字节码翻译成底层系统指令,再让CPU去执行
组件Native interface,本地接口
执行引擎翻译字节码需要调用其他语言的本地库接口,这个组件可以与Native libraries交互,实现与其他语言的交互,满足执行引擎的需求
程序计数器(Program Counter Register):私有线程,程序空间很小
正在执行的线程的字节码的行号指示器,记录JVM正在执行的线程指令地址。字节码解析器(应该是虚拟机栈里的东西)的工作是通过改变这个计数器的值,来选取下一条需要执行的字节码指令、分支、循环、跳转、异常处理、线程恢复 等基础功能。
Java虚拟机栈(Java Virtual Machine Stacks):私有线程,虚拟机用来解释Class字节码
虚拟机栈是服务java方法的,存储Java方法。每个方法 执行的时候都会创建一个栈帧,用于存储方法的信息(局部变量表、操作数栈、动态链接和方法返回 等)。 线程请求的栈深超过了虚拟机允许的最大,会抛出StackOverFlowError 异常
本地方法栈(Native Method Stack):私有线程,存的是C和C++函数
与虚拟机栈的作用是一样的,区别在于 虚拟机栈是服务java方法的,本地方法栈是为JVM通过动态链接 直接调用本地方法服的。存储本地方法(可以理解为所有非Java方法)
方法区(Method Area):用于存储每一个类的结构信息,例如,运行时常量池(runtime constant pool)、字段和方法数据、构造函数和普通方法的字节码内容,还包括一些在类、实例、接口初始化时用到的特殊方法;
jdk8以后取消永久代,方法区里的 运行时常量池 被放到heap,其他的数据存在元空间,元空间不在虚拟机中而是使用本地内存,因此默认情况下元空间的大小只受本地内存的限制。
Java堆(Java Heap):占用JVM内存大,所有线程共享
几乎所有的对象和数组都要在堆上分配内存,垃圾回收也在这里发生
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-bLQ9GqKw-1682231171117)(C:\Users\Joshua.TV\Pictures\webPhoto_Typora\watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L3dlaXhpbl80NDU1Njk2OA==,size_16,color_FFFFFF,t_70#pic_center.png)]
全称Java虚拟机栈,是Java的一种内存。保存着一个线程中方法的调用状态。换句话说,是用来执行程序的,所以虚拟机栈是线程私有的,独有的,随着线程的创建而创建,每个方法的执行对应一个栈帧,例如调用一个方法,就会向栈中压入一个栈帧;一个方法调用完成,就会把该栈帧从栈中弹出。栈中主要存放一些基本类型的变量和对象引用。
栈内存的释放
当在一段代码块定义一个变量时,Java就在栈中为这个变量分配内存空间。该变量退出其的作用域后,Java会自动释放掉为该变量所分配的内存空间,该内存空间可以立即被另作他用。
栈(stack)与堆(heap)都是Java用来在Ram(随机存储内存)中存放数据的地方。Java自动管理栈和堆,程序员不能直接地设置栈或堆。
栈内存(全称虚拟机栈) | 堆内存 |
---|---|
存放基本类型的变量和对象的引用;栈数据可以共享;先进后出 | 存放由new创建的对象和数组以及常量池 |
存取速度比堆要快,仅次于直接位于CPU中的寄存器 | 由于要在运行时动态分配内存,存取速度较慢 |
数据大小与生存期必须是确定的,缺乏灵活性 | 生存期不必事先告诉编译器; |
超出作用域后自动释放为该变量分配的内存空间 | 垃圾收集器会自动收走这些不再使用的数据 |
Java对象的访问,需要通过 虚拟机栈.reference类型的数据区 操作具体的对象。但reference类型在JVM规范中只规定了一个对象的引用,没说这个引用应该用那种方式定位。所以应该具体情况具体分析
句柄访问
Java堆会划分内存作为句柄池,reference中存储的就是对象的句柄地址,而句柄中包含对象数据的地址和对象类型数据的具体地址信息。举例
Object obj = new Object();
//Object obj表示类型的引用变量,存储在栈内存的引用变量表中,表示一个reference类型的数据
//new Object()作为对象放在堆内存中,同时堆内存还记录了Object类的信息(对象类型、实现接口、方法之类)的 地址。这些地址对应的数据类型存储在方法去(栈内存)
指针访问
使用指针访问,那么堆对象的布局中就必须考虑如何放置访问类型的相关信息。而reference中存储的就是对象的地址
句柄访问的好处 | 指针访问的好处 |
---|---|
reference中存储这稳定的句柄地址,当对象移动之后(垃圾收集时移动对象的普遍行为),只需要改变句柄中的对象地址即可,reference不用修改 | 访问速度快,减少了一次指针定位的时间开销,Java中对象的访问频繁,减少这类开销累计起来,提高了效率 |
引用计数法(判断对象的引用数量):堆中每个对象都有一个引用计数器,每当有一个地方引用它,计数器的值就加1,当引用失效时,计数器的值就减1。计数器值为0的对象可以被当作垃圾回收
可达性分析算法(判断对象的引用链是否可达):以GC Root为起点,开始往下搜索,所走过的路径称为引用链,当GC Root和一个对象之间没有任何引用链相连时,证明此对象不可用。可作为GC Root的对象包括:
在可达性分析算法中不可达的对象,暂时处于“缓刑”阶段,真正宣告一个对象死亡,至少要经历两次标记:
内存溢出 out of memory,是指程序在申请内存时,没有足够的内存空间供其使用,出现out of memory;比如申请了一个integer,但给它存了long才能存下的数,那就是内存溢出。
内存泄露 memory leak,是指程序在申请内存后,无法释放已申请的内存空间,一次内存泄露危害可以忽略,但内存泄露堆积后果很严重,无论多少内存,迟早会被占光。
memory leak会最终会导致out of memory
内存泄漏是指你向系统申请分配内存进行使用(new),可是使用完了以后却不归还(delete),结果你申请到的那块内存你自己也不能再访问(也许你把它的地址给弄丢了),而系统也不能再次将它分配给需要的程序。一个盘子用尽各种方法只能装4个果子,你装了5个,结果掉倒地上不能吃了。这就是溢出!比方说栈,栈满时再做进栈必定产生空间溢出,叫上溢,栈空时再做退栈也产生空间溢出,称为下溢。就是分配的内存不足以放下数据项序列,称为内存溢出.
一般情况下用户感受不到内存泄漏的存在,当内存泄漏堆积到发生内存溢出时,也意味着内存泄漏掉系统所有内存
字符串存在永久代中,容易出现性能问题和内存溢出。
类及方法的信息等比较难确定其大小,因此对于永久代的大小指定比较困难,太小容易出现永久代溢出,太大则容易导致老年代溢出。
永久代会为 GC 带来不必要的复杂度,并且回收效率偏低。
对于虚拟机来说,主要体现的是对象的不同的可达性(reachable) 状态和对垃圾收集(garbage collector)的影响。
可以通过下面的流程来对对象的生命周期做一个总结
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-Qlsqtsbr-1682231171119)(C:\Users\Joshua.TV\Pictures\webPhoto_Typora\ccb7b668399fb5ffe88954e7067e62c7.jpeg)]
图中用红色标明的区域表示对象处于强可达阶段。
如果只讨论符合垃圾回收条件的对象,那么只有三种:软可达、弱可达和幻象可达。
除此之外,还有强可达和不可达的两种可达性判断条件
下面是一个不同可达性状态的转换图
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-vOQQrgI0-1682231171120)(C:\Users\Joshua.TV\Pictures\webPhoto_Typora\401e596c8a01c8ea69feebc17f2df229.jpeg)]
判断可达性条件,也是 JVM 垃圾收集器决定如何处理对象的一部分考虑因素。
所有的对象可达性引用都是 java.lang.ref.Reference 的子类,它里面有一个get()方法,返回引用对象。如果已通过程序或垃圾收集器清除了此引用对象,则此方法返回 null 。也就是说,除了幻象引用外,软引用和弱引用都是可以得到对象的。而且这些对象可以人为拯救,变为强引用,例如把 this 关键字赋值给对象,只要重新和引用链上的任意一个对象建立关联即可。
如果不进行对象存货时间区分,每次垃圾回收都是对整个堆空间进行回收,会花费很长时间。每次回收都需要遍历所有存活对象,开销较大,对于生命周期长的对象,这种遍历是多余的操作。因此分代垃圾回收采用分治思想,进行代的划分
根据对象存活周期的不同将内存划分为几块。一般分为新生代和老年代
程序执行了System.gc()
3.排查步骤
注意: JVM在执行dump操作的时候是会发生stop the word事件的,也就是说此时所有的用户线程都会暂停运行。
3.1通过JVM参数获取dump文件
3.2通过JDK自带的工具jmap获取dump文件
由于CMS并发清理阶段用户线程还在运行,程序运行就还会有新的垃圾不断产生,这一部分垃圾出现在标记过程之后,CMS无法在当次收集中处理掉他们,只好留给下一次GC时再清理掉。这一部分垃圾就是“浮动垃圾”
Serial收集器:
这个收集器是一个单线程的收集器,在他进行垃圾收集时,必须暂停其他所有的工作线程,直到它收集结束。“Stop The World”。
Serial Old收集器:
Serial Old是Serial收集器的老年代版本,它同样是一个单线程收集器,老年代毫无疑问使用“标记-整理”算法。
ParNew收集器:
ParNew收集器其实就是Serial收集器的多线程版本,除了使用多条线程进行垃圾收集之外,其余行为包括Serial收集器可用的所有控制参数、收集算法、、Stop The World、对象分配规则、回收策略等都与Serial收集器完全一样,在实现上,这两种收集器也共用了相当多的代码。
Parallel Scavenge收集器:
新生代收集器,毫无疑问使用复制算法的,是并行的多线程收集器,看上去和ParNew都一样。
CMS等收集器的关注点是尽可能地缩短垃圾收集时用户线程的停顿时间,Parallel Scavenge收集器的目标则是达到一个可控制的吞吐量。吞吐量就是CPU用于运行用户代码的时间与CPU总消耗时间的比值,即吞吐量=运行用户代码时间/(运行用户代码时间+垃圾收集时间)。
停顿时间越短就越适合需要与用户交互的程序,良好的响应速度能提升用户体验,而高吞吐量则可以高效率地利用CPU时间,尽快完成程序的运算任务,主要适合在后台运算而不需要太多交互的任务。由于与吞吐量关系密切,Parallel Scavenge收集器也经常称为**“吞吐量优先”收集器。**
Parallel Old:是Parallel Scavenge收集器的老年代版本,使用多线程和“标记-整理”算法。
CMS收集器:
HotSpot VM第一款真正意义上的并发收集器,它第一次实现了让垃圾收集线程与用户线程(基本上)同时工作。CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器。目前很大一部分的Java应用集中在互联网站或者B/S系统的服务端上,这类应用尤其重视服务的响应速度,希望系统停顿时间最短,以给用户带来较好的体验。CMS收集器就非常符合这类应用的需求。从名字(Concurrent Mark Sweep)上就可以看出,CMS收集器是基于“标记-清除”算法实现的,它的运作过程相对于前面几种收集器来说更复杂一些,整个过程分为4个步骤,包括:
CMS的优点:并发收集、低停顿。
CMS缺点:
1、CMS收集器对CPU资源非常敏感。其实,面向并发设计的程序都对CPU资源比较敏感。
2、CMS收集器无法处理浮动垃圾。
3、CMS是一款基于“标记-清除”算法实现的收集器,这意味着收集结束时会有大量空间碎片产生。
G1(Garbage-First)收集器:
G1是一款面向服务端应用的垃圾收集器。HotSpot开发团队赋予它的使命是(在比较长期的)未来可以替换掉JDK1.5
中发布的CMS收集器。与其他GC收集器相比,G1具备如下特点。
1、并发与并行
2、分代收集
虽然G1可以不需要其他收集器配合就能独立管理整个GC堆,但他能够采用不同的方式去处理新创建的对象和已经存活了
一段时间。熬过多次GC的旧对象以获取更好地收集效果。
3、空间整合
与CMS的“标记-清理”算法不同,G1从整体来看是基于“标记-整理”算法实现的收集器,从局部(两个Region之间)上来看是基于
“复制”算法实现的,但无论如何,这两种算法都意味着G1运作期间不会产生内存空间碎片,收集后能提供规整的可用内存。
这种特性有利于程序长时间运行,分配大对象时不会因为无法找到连续内存空间而提前触发下一次GC。
4、可预测的停顿
这是G1相对于CMS的另一大优势,降低停顿时间是G1和CMS共同的关注点,但G1除了追求低停顿外,还能建立可预测的停顿时间模型。在G1之前的其他收集器进行收集的范围都是整个新生代或者老年代,而G1不再是这样。使用G1收集器时,Java堆的内存布局就与其他收集器有很大差别,它将整个Java堆划分为多个大小相等的独立区域(Region),虽然还保留有新生代和老年代的概念,但新生代和老年代不再是物理隔离的了,它们都是一部分Region(不需要连续)的集合。G1收集器之所以能建立可预测的停顿时间模型,是因为它可以有计划地避免在整个Java堆中进行全区域的垃圾收集。G1跟踪各个Region里面的垃圾堆积的价值大小(回收所获得的空间大小以及回收所需时间的经验值),在后台维护一个优先列表,每次根据允许的收集时间,优先回收价值最大的Region(这也就是Garbage-First名称的来由)。这种使用Region划分内存空间以及有优先级的区域回收方式,保证了G1收集器在有限的时间内可以获取尽可能高的收集效率。
G1收集器的运作大致可划分为以下几个步骤:
1、初始标记 2、并发标记 3、最终标记 4、筛选回收
虚拟机把描述类的数据从Class文件加载到内存,并对数据进行校验、转换解析和初始化,最终形成可以被虚拟机直接使用的Java类型,这就是虚拟机的类加载机制。
整个生命周期包括:加载、验证、准备、解析、初始化、使用和卸载7个阶段。其中验证、准备、解析3个部分统称为连接。
“加载”是“类加载”过程的一个阶段,在加载阶段,虚拟机需要完成以下3件事情:
1、通过一个类的全限定名来获取定义此类的二进制字节流。
2、将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构、
3、在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口。
验证是连接阶段的第一步,这一阶段的目的是为了确保Class文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身的安全。(连接:验证、准备、解析)
准备阶段是正式为类变量分配内存并设置类变量初始值的阶段,这些变量所使用的内存都将在方法区中进行分配。这时候进行内存分配的仅包括类变量(被static修饰的变量),而不包括实例变量,实例变量将会在对象实例化时随着对象一起分配在Java堆中。
解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程。
类初始化阶段是类加载过程的最后一步,前面的类加载过程中,除了在加载阶段用户应用程序可以通过自定义类加载器参与之外,其余动作完全由虚拟机主导和控制。到了初始化阶段,才真正开始执行类中定义的Java程序代码(或者说是字节码)。在准备阶段,变量已经赋过一次系统要求的初始值,而在初始化阶段,则根据程序员通过程序制定的主观计划去初始化类变量和其他资源,或者可以从另外一个角度来表达:初始化阶段是执行类构造器()方法的过程。
虚拟机设计团队把类加载阶段中的“通过一个类的全限定名来获取描述此类的二进制字节流”这个动作放到Java虚拟机外部去实现,以便让应用程序自己决定如何去获取所需要的类。实现这个动作的代码模块称为“类加载器”。
类加载器虽然只用于实现类的加载动作,但它在Java程序中起到的作用却远远不限于类加载阶段。对于任意一个类,都需要由加载它的类加载器和这个类本身一同确立其在Java虚拟机中的唯一性,每一个类加载器,都拥有一个独立的类名称空间。这句话可以表达得更通俗一些:比较两个类是否“相等”,只有在这两个类是由同一个类加载器加载的前提下才有意义,否则,即使这两个类是来源于同一个Class文件,被同一个虚拟机加载,只要加载它们的类加载器不同,那这两个类就必定不相等。
从Java虚拟机的角度来讲,只存在两种不同的类加载器:
启动类加载器(Bootstrap ClassLoader),这个类加载器使用C++语言实现,是虚拟机自身的一部分;
所有其他的类加载器,这些类加载器都由Java语言实现,独立于虚拟机外部,并且全部继承自抽象类java.lang.ClassLoader。从Java开发人员的角度来看,类加载器还可以划分得更细致一些,绝大部分Java程序都会使用到以下3种系统提供的类加载器。
启动类加载器、扩展类加载器、应用程序类加载器。我们的应用程序都是由这3种类加载器互相配合进行加载的,如果有必要,还可以加入自己定义的类加载器。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-JIJaDk3k-1682231171121)(C:\Users\Joshua.TV\Pictures\webPhoto_Typora\c6ba99fae2f73352dbd7dbbdd4bd7b69.png)]
图中展示的类加载器之间的这种层次关系,称为类加载器的双亲委派模型。双亲委派模型要求除了顶层的启动类加载器外,其余的类加载器都应当有自己的父加载器。类加载器之间的关系一般不会以继承的关系实现,而是使用组合关系来复用父加载器的代码。
双亲委派模型的工作过程是:如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成,每一个层次的类加载器都是如此,因此所有的加载请求最终都应该传送到顶层的启动类加载器中,只有当父加载器反馈自己无法完成这个加载请求(它的搜索范围中没有找到所需的类)时,子加载器才会尝试自己去加载。
使用双亲委派模型来组织类加载器之间的关系,好处就是Java类随着它的类加载器一起具备了一种带有优先级的层次关系。
## 你对内存模型的理解
程序在运行过程中,会将运算需要的数据从主存复制一份到 CPU 的高速缓存当中。那么 CPU 进行计算时就可以直接从它的高速缓存读取数据和向其中写入数据,当运算结束之后,再将高速缓存中的数据刷新到主存当中。而随着 CPU 能力的不断提升,一层缓存就慢慢的无法满足要求了,就逐渐的衍生出多级缓存。按照数据读取顺序和与 CPU 结合的紧密程度,CPU 缓存可以分为一级缓存(L1),二级缓存(L2),部分高端 CPU 还具有三级缓存(L3),每一级缓存中所储存的全部数据都是下一级缓存的一部分。这三种缓存的技术难度和制造成本是相对递减的,所以其容量也是相对递增的。那么,在有了多级缓存之后,程序的执行就变成了:当 CPU 要读取一个数据时,首先从一级缓存中查找,如果没有找到再从二级缓存中查找,如果还是没有就从三级缓存或内存中查找。
并发编程:原子性、有序性、可见性
处理器优化:为了使处理器内部的运算单元能够被充分利用,处理器可能会对输入代码进行乱序执行处理。
指令重排:现在很多流行的处理器会对代码进行优化乱序处理,很多编程语言的编译器也会有类似的优化,比如 Java 虚拟机的即时编译器(JIT)也会做指令重排。
缓存一致性问题就是可见性问题,处理器优化会导致原子性问题,指令重排会导致有序性问题。
缓存一致性问题、处理器优化的指令重排问题是硬件的不断升级导致的。那么,有没有什么机制可以很好的解决上面的这些问题呢?
最简单直接的做法就是废除处理器和处理器的优化技术、废除 CPU 缓存,让 CPU 直接和主存交互。但是,这么做虽然可以保证多线程下的并发问题。但是,这就有点因噎废食了。所以,为了保证并发编程中可以满足原子性、可见性及有序性。有一个重要的概念,那就是——内存模型。
为了保证共享内存的正确性(可见性、有序性、原子性),内存模型定义了共享内存系统中多线程程序读写操作行为的规范。通过这些规则来规范对内存的读写操作,从而保证指令执行的正确性。它与处理器有关、与缓存有关、与并发有关、与编译器也有关。它解决了 CPU 多级缓存、处理器优化、指令重排等导致的内存访问问题,保证了并发场景下的一致性、原子性和有序性。
内存模型解决并发问题主要采用两种方式:
本文就不深入底层原理来展开介绍了,感兴趣的朋友可以自行学习。
前面介绍了计算机内存模型,这是解决多线程场景下并发问题的一个重要规范。
那么具体的实现是如何的呢?不同的编程语言,在实现上可能有所不同。
我们知道,Java 程序是需要运行在 Java 虚拟机上面的,Java 内存模型(Java Memory Model,JMM)就是一种符合内存模型规范的,屏蔽了各种硬件和操作系统的访问差异的,保证了 Java 程序在各种平台下对内存的访问都能保证效果一致的机制及规范。Java 内存模型规定了所有的变量都存储在主内存中,每条线程还有自己的工作内存。线程的工作内存中保存了该线程中用到的变量的主内存副本拷贝,线程对变量的所有操作都必须在工作内存中进行,而不能直接读写主内存。不同的线程之间也无法直接访问对方工作内存中的变量,线程间变量的传递均需要自己的工作内存和主存之间进行数据同步进行。而 JMM 就作用于工作内存和主存之间数据同步过程。它规定了如何做数据同步以及什么时候做数据同步。这里面提到的主内存和工作内存,读者可以简单的类比成计算机内存模型中的主存和缓存的概念。特别需要注意的是,主内存和工作内存与 JVM 内存结构中的 Java 堆、栈、方法区等并不是同一个层次的内存划分,无法直接类比。
在 Java 中提供了一系列和并发处理相关的关键字,比如 Volatile、Synchronized、Final、Concurren 包等。其实这些就是 Java 内存模型封装了底层的实现后提供给程序员使用的一些关键字。在开发多线程的代码的时候,我们可以直接使用 Synchronized 等关键字来控制并发,这样就不需要关心底层的编译器优化、缓存一致性等问题。所以,Java 内存模型,除了定义了一套规范,还提供了一系列原语,封装了底层实现后,供开发者直接使用。
我们前面提到,并发编程要解决原子性、有序性和一致性的问题。下面我们就再来看下,在 Java 中,分别使用什么方式来保证。
原子性
在 Java 中,为了保证原子性,提供了两个高级的字节码指令 Monitorenter 和 Monitorexit。
在 Synchronized 的实现原理文章中,介绍过,这两个字节码,在 Java 中对应的关键字就是 Synchronized。
因此,在 Java 中可以使用 Synchronized 来保证方法和代码块内的操作是原子性的。
有序性
在 Java 中,可以使用 Synchronized 和 Volatile 来保证多线程之间操作的有序性。
实现方式有所区别:Volatile 关键字会禁止指令重排。Synchronized 关键字保证同一时刻只允许一条线程操作。
可见性
Java 内存模型是通过在变量修改后将新值同步回主内存,在变量读取前从主内存刷新变量值的这种依赖主内存作为传递媒介的方式来实现的。Java 中的 Volatile 关键字提供了一个功能,那就是被其修饰的变量在被修改后可以立即同步到主内存。被其修饰的变量在每次使用之前都从主内存刷新。因此,可以使用 Volatile 来保证多线程操作时变量的可见性。除了 Volatile,Java 中的 Synchronized 和 Final 两个关键字也可以实现可见性。只不过实现方式不同,这里不再展开了。
读者可能发现了,好像 Synchronized 关键字,可以同时满足以上三种特性,这也是很多人滥用 Synchronized 的原因。
但是 Synchronized 是比较影响性能的,虽然编译器提供了很多锁优化技术,但是也不建议过度使用。
基本类型变量分析
int x = 1;
int y = 1;
x = 2;
处理步骤:
以上是数据的共享,这种情况a的修改并不会影响到b,有利于节省内存空间,但如果是两个对象的引用同时指向一个对象,一个对象的改变会影响到另一个对象的引用变量。
引用类型变量分析
String str = new String("aaa");
String str = "aaa";
以上两种创建方式,看似结果是一样的,但是创建的过程是不同,第一种用new()来新建对象,它会放于堆中,每new一次,就新建一个对象;第二种是在栈中创建一个引用变量str,然后查找栈中是否有“aaa”,若有直接将引用变量str指向“aaa”,若没有,则先存放aaa后并指向它。
测试两个包装类的引用是否指向同一个对象时,用==,对比值或字符串的时候用equals()。
String str1 = "bbb";
String str2 = "bbb";
System.out.println(str1==str2); //true
上面的运行结果为true,即str1和str2是指向同一个对象的。
String str1 = new String ("ccc");
String str2 = new String ("ccc");
System.out.println(str1==str2); //false
上面的运行结果为false,即str1和str2是指向不同的对象,每new一次生成一个对象。
所以String s = "a"这种方式创建字符串,无论创建几个对象,在内存中其实只存在一个对象而已,数据进行了共享,节省的内存空间;而用new()方法才能保证每创建一次就是一个新的对象。就是因为String的不可变性,字符串连接效率低,每次连接都新建字符串对象,可以用StringBuffer或StringBuilder来代替,StringBuffer线程安全,但是效率低,适用于多线程;而StringBuilder线程不安全但是效率高,适用于不考虑线程安全的单线程环境。
一、类的二进制字节码包含哪些信息?
常量池
类的基本信息(比如:类的访问权限、类的名称、实现了哪些接口)
类的方法定义(包含了虚拟机指令,也就是把我们代码编译为了虚拟机指令 )
二、通过反编译字节码验证
1、测试代码
将下面的测试代码使用javac 编译为 *.class文件
public class HelloWorld {
public static void main(String[] args) {
System.out.println("hello world");
}
}
2、javap反编译*.class字节码
先将示例代码编译为 *.class 文件,然后将class文件反编译为JVM指令码。然后观察 *.class字节码中到底包含了哪些部分。
// ===========================================类的描述信息===============================================
Classfile /xx/xx/xx/xx/HelloWorld.class
Last modified 2021-10-12; size 569 bytes
MD5 checksum 7f4f0fe4b6e6d04ddaf30401a7b04f07
Compiled from "HelloWorld.java"
public class org.memory.jvm.t5.HelloWorld
minor version: 0
major version: 49
flags: ACC_PUBLIC, ACC_SUPER
// ===========================================常量池===============================================
Constant pool:
#1 = Methodref #6.#20 // java/lang/Object."":()V
#2 = Fieldref #21.#22 // java/lang/System.out:Ljava/io/PrintStream;
#3 = String #23 // hello world
#4 = Methodref #24.#25 // java/io/PrintStream.println:(Ljava/lang/String;)V
#5 = Class #26 // org/memory/jvm/t5/HelloWorld
#6 = Class #27 // java/lang/Object
#7 = Utf8 <init>
#8 = Utf8 ()V
#9 = Utf8 Code
#10 = Utf8 LineNumberTable
#11 = Utf8 LocalVariableTable
#12 = Utf8 this
#13 = Utf8 Lorg/memory/jvm/t5/HelloWorld;
#14 = Utf8 main
#15 = Utf8 ([Ljava/lang/String;)V
#16 = Utf8 args
#17 = Utf8 [Ljava/lang/String;
#18 = Utf8 SourceFile
#19 = Utf8 HelloWorld.java
#20 = NameAndType #7:#8 // "":()V
#21 = Class #28 // java/lang/System
#22 = NameAndType #29:#30 // out:Ljava/io/PrintStream;
#23 = Utf8 hello world
#24 = Class #31 // java/io/PrintStream
#25 = NameAndType #32:#33 // println:(Ljava/lang/String;)V
#26 = Utf8 org/memory/jvm/t5/HelloWorld
#27 = Utf8 java/lang/Object
#28 = Utf8 java/lang/System
#29 = Utf8 out
#30 = Utf8 Ljava/io/PrintStream;
#31 = Utf8 java/io/PrintStream
#32 = Utf8 println
#33 = Utf8 (Ljava/lang/String;)V
// =======================================虚拟机中执行编译的方法===========================================
{
public org.memory.jvm.t5.HelloWorld();
descriptor: ()V
flags: ACC_PUBLIC
Code:
stack=1, locals=1, args_size=1
0: aload_0
1: invokespecial #1 // Method java/lang/Object."":()V
4: return
LineNumberTable:
line 7: 0
LocalVariableTable:
Start Length Slot Name Signature
0 5 0 this Lorg/memory/jvm/t5/HelloWorld;
// main方法JVM指令码
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
// main方法访问修饰符描述
flags: ACC_PUBLIC, ACC_STATIC
// main方法中的代码执行部分
// ===============================解释器读取下面的JVM指令解释并执行===================================
Code:
stack=2, locals=1, args_size=1
// 从常量池中符号地址为 #2 的地方,先获取静态变量System.out
0: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream;
// 从常量池中符号地址为 #3 的地方加载常量 hello world
3: ldc #3 // String hello world
// 从常量池中符号地址为 #3 的地方获取要执行的方法描述,并执行方法输出hello world
5: invokevirtual #4 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
// main方法返回
8: return
// ==================================解释器读取上面的JVM指令解释并执行================================
// 行号映射表
LineNumberTable:
line 9: 0
line 10: 8
// 局部变量表
LocalVariableTable:
Start Length Slot Name Signature
0 9 0 args [Ljava/lang/String;
}
三、什么是常量池以及常量池的作用?
1、什么是常量池
从上面的反编译字节码中可以看到,Class的常量池其实就是一张记录着该类的一些常量、方法描述、类描述、变量描述信息的表。
2、常量池中有什么内容
常量池中主要存放两类数据,一是字面量、二是符号引用。
3、常量池的作用
在解释器解释执行每条JVM指令码的时候,根据这些指令码的符号地址去常量池中找到对应的描述。然后解释器就知道该执行哪个类的那个方法、方法的参数是什么等。
拿上面反编译的字节码指令来说明:
// main方法JVM指令码
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
// main方法访问修饰符描述
flags: ACC_PUBLIC, ACC_STATIC
// main方法中的代码执行部分
// ===============================解释器读取下面的JVM指令解释并执行===================================
Code:
stack=2, locals=1, args_size=1
// 从常量池中符号地址为 #2 的地方,先获取静态变量System.out
0: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream;
// 从常量池中符号地址为 #3 的地方加载常量 hello world
3: ldc #3 // String hello world
// 从常量池中符号地址为 #3 的地方获取要执行的方法描述,并执行方法输出hello world
5: invokevirtual #4 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
// main方法返回
8: return
// ==================================解释器读取上面的JVM指令解释并执行================================
四、运行时常量池
1、什么是运行时常量池
上面我们分析了常量池其实就是一张对照表,常量池是 *.class 文件中的。当类的字节码被加载到内存中后,他的常量池信息就会集中放入到一块内存,这块内存就称为运行时常量池,并且把里面的符号地址变为真实地址。
2、符号地址变为真实地址怎么理解
①、符号地址
从上面的反编译后的JVM字节码指令可以看到有这么一条指令0: getstatic #2,解释器解释执行JVM指令的时候,通过指令中的 #x去常量池中获取需要的值。这里的#2其实就是符号地址,标识这某个变量在常量池中的某个位置。
②、真实地址
在程序运行期,当*.Class文件被加载到内存以后,常量池中的这些描述信息就会被放到内存中,其中的 #x会被转化为内存中的地址(真实地址)。
符号地址变为真实地址其实就是,在*.class文件被加载到内存以后,将*.class文件中常量池中的#x符号地址,转化为内存中的地址。
没什么好总结的