认识Java

126 阅读43分钟

还记得一开始学习编程的时候,还是在学校,莫名其妙就走上了Java这一条路,其中最早接触的是C语言,然后是C++、C#,还有前端的静态语言Html、css和js等等。最后在大二下学期才学了一个学习的Java语言。后面的大三自己钻研,大四出去实习都选择了这个,原来还是有打算去做网络工程师的,或者数据库安全工程师的。莫名其妙的走到了今天码农的一步,应该算是大势所趋,顺应时代的号召和响应。

所以有啥不好的吗?一开始选择计算机这个行业,我也是不知道这个行业会火,我对这个行业也没有很热爱,慢慢靠着自己微薄的好奇心,才走到了今天,多问自己为什么?和实际手动操作,去试试自己可以做到的所有可能。我感觉计算机要学的东西真的很多很多,四大基础[计算机组成原理、计算机网络、操作系统、算法],每一个进去都是无底洞,而大学只是给了我一个机会去浅尝辄止的机会、我没有时间,也没有机会自己的去深入的了解这些,我就了解一些简单的东西。

在这里我只是一个普通人,一个很平凡的人,所以我的人生我想尽量的追求安稳和平淡,做好自己的事情,追求自己想要的生活,明白自己为什么活着,过的快乐一点就好了。

收录一句名言:华罗庚 “把一本书读厚,然后再读薄”

提醒:这个文章只是我自己从前辈大佬的文章中截取的一些片段,相当于学习笔记,涉及了好几个网站,不适合初学者哦,因为这些是我想要记录的东西,害怕某一天突然想着回过头来看的时候,这些信息都没了。

Java语言支持四种类型

接口(包含注释)、类(包含枚举enum)、数组和基本类型
前三者通常称之为引用类型(reference type),类实例和数组是对象(object),而基本类型的值不是对象。
类的成员(member)由它的域(field)、方法(method)、成员类(member class)、和成员接口(member interface)组成。
方法的签名(signature)由它的名称和所有参数类型组成,签名不包含方法的返回值。

Java的特点

  • 面向对象编程(Object-Oriented Programming,简称 OOP):封装、继承、多态、(抽象)。
  • 平台无关性:一次编程到处运行,这是建立的在JVM虚拟机的基础上实现的。
  • 支持多线程,以及线程之间的通信和共享
  • 编译和解释并存

封装、继承、多态

封装:注意 class
继承:注意 extends、implements
多态:

你知道Java类的三种模型吗?

  1. 贫血模型:只有基本的属性、赋值获取值方法,以及一些构造方法不涉及业务逻辑。
  2. 充血模型:在贫血模型的基础上,有相关与当前类的业务逻辑实现,但是不包含事务回滚这些的类。
  3. 胀血模型:在充血模型的基础上,包含了事务的回滚等等。

作为一个拓展知识点,一开始听到的时候,挺新奇的,所以就粗略的看了一下,其实对于Java类还有很多的知识点:什么是JavaBean啊?Spring中的Bean是啥等等

JDK组成

image.png 图片来源
其实看下来把握主要内容就好:jdkjrejvm )+ java class & tools & apis)

Java程序分为三步运行

  1. 编译:将我们的代码(.java)编译成虚拟机可以识别理解的字节码(.class)
  2. 解释:虚拟机执行Java字节码,将字节码翻译成机器能识别的机器码
  3. 执行:对应的机器执行二进制机器码

详细流程图: 图片来源

image.pngjavac源代码编译器 这过程还是有很多细节可以挖掘的:

  • 词法分析
  • 语法分析
  • 抽象语法树
  • 语义分析
  • 注解抽象语法树
  • 字节码生成器

从 .class -> 机器码 这个过程也有很多细节可以挖掘的:

image.png

  • 大部分代码正常过程:JVM 类加载器首先加载字节码文件,然后通过解释器逐行解释执行,相对速度很慢。这里就会出现一些重复调用的方法或者代码【热点代码】可以进一步优化性能。
  • 引进 JIT(just-in-time compilation) 编译器:运行时编译,当 JIT 编译器对热点代码完成第一次编译后,其会将字节码对应的机器码保存下来,下次可以直接使用,不需要重复的编译。

HotSpot 采用惰性评估(Lazy Evaluation)的做法(二八定律):消耗大部分系统资源的只有那一小部分的代码(热点代码),而这也就是 JIT 所需要编译的部分。
JVM 会根据代码每次被执行的情况收集信息并相应地做出一些优化,因此执行的次数越多,它的速度就越快,对热点代码的定位也就越准确。
JDK 9 引入了一种新的编译模式 AOT(Ahead of Time Compilation),它是直接将字节码编译成机器码,这样就避免了 JIT 预热等各方面的开销。JDK 支持分层编译和 AOT 协作使用。

这里对于JDK 9为什么不选择全部使用AOT做提前编译,节省程序的启动时间:主要原因应该是动态代理
CGLIB 动态代理使用的是 ASM 技术,而这种技术大致原理是运行时直接在内存中生成并加载修改后的字节码文件也就是 .class 文件,如果全部使用 AOT 提前编译,也就不能使用 ASM 技术了。为了支持类似的动态特性,所以选择使用 JIT 即时编译器。Spring框架也有自己的动态代理,不知道两者是否有区别?

编译和解释并存

个人理解:

  • 编译这个阶段主要体现在 .java -> .class,也就是通过javac源代码编译器进行的阶段。
  • 解释这个阶段主要体现在 .class -> 机器码 也就是通过jvm的解释器和JIT等进行的阶段。

为了改善编译语言的效率而发展出的即时编译技术,已经缩小了这两种语言间的差距。这种技术混合了编译语言与解释型语言的优点,它像编译语言一样,先把程序源代码编译成字节码。到执行期时,再将字节码直译,之后执行。Java与LLVM是这种技术的代表产物。

平台无关性

image.png 图片来源

编译和解释并存拓展

分享一篇超级厉害的文章,以下内容来自该文章
木桶理论(一只水桶能装多少水取决于它最短的那块木板):CPU、内存、I/O 设备
为了平衡这三者的速度差异,计算机体系结构、操作系统、编译程序都做出了贡献,主要体现为:

  • 现代计算机在CPU 增加了缓存,以均衡与内存的速度差异
  • 操作系统增加了进程、线程,以分时复用 CPU,进而均衡 CPU 与 I/O 设备的速度差异
  • 编译程序优化指令执行次序,使得缓存能够得到更加合理地利用

Java性能优化主要分为两个方面:业务优化和技术优化

  • 业务优化:影响巨大,会改变业务的需求和业务场景的适配,相当于业务重构
  • 技术优化:可以从这几个方面进行优化,复用优化,结果集合优化,高效实现优化,算法优化,计算优化,资源冲突优化和JVM优化。

技术优化基本都集中在计算机资源和存储资源的规划上,最直接的就是对于服务器和业务应用程序相关的资源做具体的分析,在照顾性能的前提下,同时也兼顾业务需求的要求,从而达到资源利用最优的状态。一味地强调利用空间换时间的方式,只看计算速度,不考虑复杂性和空间的问题,确实有点不可取。特别是在云原生时代下和无服务时代,虽然模糊和减少了开发对这些问题的距离,但是我们更加需要了解和关注这些问题的实质。

JVM 虚拟机

image.png 以上是JVM的内存分布了,具体一个程序运行了,对象和属性怎么存放的,以及其中GC是如何操作的,有一大堆的内容,需要通过自己慢慢的了解,不可能一蹴而就的都懂了,多在实际程序运行中去套去进行自己的猜测,才会慢慢的熟悉这个块的内容。
从Java程序运行到交给JVM托管,借用一张图:

image.png 主要关注JVM托管这一块,编译和解释前面的已经有详细的介绍了:

JVM托管:其实也就是平台无关性的具体实现。
这个托管有什么好处:

  • 代替人类处理一些代码中冗长而且容易出错的部分,以及自动做一些操作:垃圾回收、自动内存管理。
  • 减少无关业务代码逻辑的书写:数组越界、动态类型、安全权限等等的动态检测。

image.png

从虚拟机视角来看,执行 Java 代码首先需要将它编译而成的 class 文件加载到 Java 虚拟机中。加载后的 Java 类会被存放于方法区(Method Area)中,Java虚拟机在内存中会划分栈和堆来存储运行时数据。

Java 虚拟机会将栈细分为面向 Java 方法的 Java 方法栈,面向本地方法(用 C++ 写的 native 方法)的本地方法栈,以及存放各个线程执行位置的 PC 寄存器

在实际运行过程中,每当调用进入一个 Java 方法,Java 虚拟机会在当前线程的 Java 方法栈中生成一个栈帧,用以存放局部变量以及字节码的操作数。这个栈帧的大小是提前计算好的,而且 Java 虚拟机不要求栈帧在内存空间里连续分布

当退出当前执行的方法时,不管是正常返回还是异常返回,Java 虚拟机均会弹出当前线程的当前栈帧,并将之舍弃。

在 Java 9 之前:

  • 启动类加载器(bootstrap class loader):负责加载最为基础、最为重要的类,比如存放在 JRE 的 lib 目录下 jar 包中的类(以及由虚拟机参数 -Xbootclasspath 指定的类)。
  • 扩展类加载器(extension class loader):其父类加载器是启动类加载器。它负责加载相对次要、但又通用的类,比如存放在 JRE 的 lib/ext 目录下 jar 包中的类(以及由系统变量 java.ext.dirs 指定的类)。
  • 应用类加载器(application class loader):其父类加载器则是扩展类加载器。它负责加载应用程序路径下的类。(这里的应用程序路径,便是指虚拟机参数 -cp/-classpath、系统变量 java.class.path 或环境变量 CLASSPATH 所指定的路径。)默认情况下,应用程序中包含的类便是由应用类加载器加载的。

启动类加载器是由C++实现的,没有对应的 Java 对象,因此在 Java 中只能用 null 来指代。

其他的类加载器都是 java.lang.ClassLoader 的子类,因此有对应的 Java 对象。这些类加载器需要先由另一个类加载器,比如说启动类加载器,加载至 Java 虚拟机中,方能执行类加载。

在Java 9 引入了模块系统:

  • 扩展类加载器改名为平台类加载器(platform class loader)
  • Java SE 中除了少数几个关键模块,比如说 java.base 是由启动类加载器加载之外,其他的模块均由平台类加载器所加载。
  • 除了由 Java 核心类库提供的类加载器外,我们还可以加入自定义的类加载器,来实现特殊的加载方式.比如说需要对class类加密解密的操作就可以使用到自定义类加载器。
  • 类加载器还提供了命名空间的作用。在 Java 虚拟机中,类的唯一性是由类加载器实例以及类的全名一同确定的。即便是同一串字节流,经由不同的类加载器加载,也会得到两个不同的类。在大型应用中,我们往往借助这一特性,来运行同一个类的不同版本。

Java 对象创建过程

image.png

Java类的创建过程:加载、链接、初始化

  • 加载:指查找字节流,并且据此创建类的过程。加载需要借助类加载器,在 Java 虚拟机中,类加载器使用了双亲委派模型,即接收到加载请求时,会先将请求转发给父类加载器。
  • 链接:指将创建成的类合并至 Java 虚拟机中,使之能够执行的过程。链接还分验证、准备和解析三个阶段。其中,解析阶段为非必须的。
  • 初始化:为标记为常量值的字段赋值,以及执行 < clinit > 方法的过程。类的初始化仅会被执行一次,这个特性被用来实现单例的延迟初始化。

引用类型:类、接口、数组类和泛型参数(泛型参数会在编译过程中被擦除),数组类是由 Java 虚拟机直接生成的,其他两种则有对应的字节流。

Java 编译过程

image.png 编译的过程有两种方式:

  • 解释执行:即逐条将字节码翻译成机器码并执行;
  • 即时编译(Just-In-Time compilation,JIT),即将一个方法中包含的所有字节码编译成机器码后再执行。

理论上讲,即时编译后的 Java 程序的执行效率,是可能超过 C++ 程序的。这是因为与静态编译相比,即时编译拥有程序的运行时信息,并且能够根据这个信息做出相应的优化。

HotSpot 内置了多个即时编译器:C1、C2 和 Graal

  • Graal 是 Java 10 正式引入的实验性即时编译器;
  • C1 又叫做 Client 编译器,面向的是对启动性能有要求的客户端 GUI 程序,采用的优化手段相对简单,因此编译时间较短
  • C2 又叫做 Server 编译器,面向的是对峰值性能有要求的服务器端程序,采用的优化手段相对复杂,因此编译时间较长,但同时生成代码的执行效率较高。

之所以引入多个即时编译器,是为了在编译时间和生成代码的执行效率之间进行取舍。

从 Java 7 开始,HotSpot 默认采用分层编译的方式:热点方法首先会被 C1 编译,而后热点方法中的热点会进一步被 C2 编译。HotSpot 会根据 CPU 的数量设置编译线程的数目,并且按 1:2 的比例配置给 C1 及 C2 编译器。

不干扰应用的正常运行,HotSpot 的即时编译是放在额外的编译线程中进行的。

计算资源充足的情况下,字节码的解释执行和即时编译可同时进行。编译完成后的机器码会在下次调用该方法时启用,以替换原本的解释执行。

Java 虚拟机结构

222.jpeg Java虚拟机:
组成结构:指令集合,指令解析器,程序执行指令
代码执行过程:字节码指令、字节码解释器、JIT即时编译器、JVM 操作内存。

Java GC垃圾回收

333.jpeg

常见的垃圾回收器:

  • Serial GC(Serial Garbage Collection):第一代GC,是1999年在JDK1.3中发布的串行方式的单线程GC。一般适用于 最小化地使用内存和并行开销的场景。

  • Parallel GC(Parallel Garbage Collection):第二代GC,是2002年在JDK1.4.2中发布的,相比Serial GC,基于多线程方式加速运行垃圾回收,在JDK6版本之后成为Hotspot VM的默认GC。一般是最大化应用程序的吞吐量。

  • CMS GC(Concurrent Mark Sweep Garbage Collection ):第二代GC,是2002年在JDK1.4.2中发布的,相比Serial GC,基于多线程方式加速运行垃圾回收,可以让应用程序和GC分享处理器资源的GC。一般是最小化GC的中断和停顿时间的场景。

  • G1 GC (Garbage First Garbage Collection):第三代GC,是JDK7版本中诞生的一个并行回收器,主要是针对“垃圾优先”的原则而诞生的GC,也是时下我们比较新的GC。

JVM中采用的垃圾收集算法主要有:

  • 标记-清除算法(Mark-Sweep ): 最基础的垃圾回收算法,分为两个阶段,标注和清除。标记阶段标记出所有需要回收的对象,清除阶段回收被标记的对象所占用的空间。该算法最大的问题是内存碎片化严重,后续可能发生大对象不能找到可利用空间的问题。

image.png

优点:实现简单,内存管理开销小。
缺点:会产生大量内存碎片,可能导致较大的对象无法分配。在清除阶段,所有的对象都需要被扫描,这可能导致效率较低。同时,清除过程可能会导致应用程序的暂停,这被称为“Stop-The-World”事件。

  • 复制算法(Copying): 为了解决Mark-Sweep算法内存碎片化的缺陷而被提出的算法。按内存容量将内存划分为等大小的两块。每次只使用其中一块,当这一块内存满后将尚存活的对象复制到另一块上去,把已使用的内存清掉。这种算法虽然实现简单,内存效率高,不易产生碎片,但是最大的问题是可用内存被压缩到了原本的一半。且存活对象增多的话,Copying算法的效率会大大降低。

image.png

优点:避免了内存碎片问题,因为对象被复制到新的内存区域。这使得分配过程更加快速和简单。
缺点:需要额外的内存空间来存储复制的对象,这可能会限制可用的内存大小。同时,复制过程可能会导致应用程序的暂停,这被称为“Stop-The-World”事件。此外,复制算法在处理大量存活对象时效率较低。

  • 标记-压缩算法(Mark-Compact): 为了避免缺陷而提出。标记阶段和Mark-Sweep算法相同,标记后不是清理对象,而是将存活对象移向内存的一端,然后清除端边界外的对象。

image.png

优点:避免了内存碎片问题,因为对象被移动到新的内存区域。与复制算法相比,标记整理算法不需要额外的内存空间。
缺点:在整理阶段,所有的对象都需要被移动,这可能导致效率较低。同时,整理过程可能会导致应用程序的暂停,这被称为“Stop-The-World”事件。此外,标记整理算法在处理大量存活对象时效率较低。

  • 增量算法(Incremental Collecting): 也可以称为分区收集算法(Region Collenting),将整个堆空间划分为连续的不同小区间, 每个小区间独立使用, 独立回收. 这样做的好处是可以控制一次回收多少个小区间 , 根据目标停顿时间, 每次合理地回收若干个小区间(而不是整个堆), 从而减少一次GC所产生的停顿。

  • 分代收集算法(Generational Collenting): 是目前大部分JVM所采用的方法,其核心思想是根据对象存活的不同生命周期将内存划分为不同的域,一般情况下将GC堆划分为老生代(Tenured/Old Generation)和新生代(Young Generation)。老生代的特点是每次垃圾回收时只有少量对象需要被回收,新生代的特点是每次垃圾回收时都有大量垃圾需要被回收,因此可以根据不同区域选择不同的算法。

确定对象是否为垃圾的两种算法:
引用计数法
给对象中添加一个引用计数器:

  • 每当有一个地方引用它,计数器就加 1;
  • 当引用失效,计数器就减 1;
  • 任何时候计数器为 0 的对象就是不可能再被使用的。

这个方法实现简单,效率高,但是目前主流的虚拟机中并没有选择这个算法来管理内存,其最主要的原因是它很难解决对象之间循环引用的问题

可达性分析算法
通过一系列的称为 “GC Roots” 的对象作为起点,从这些节点开始向下搜索,节点所走过的路径称为引用链,当一个对象到 GC Roots 没有任何引用链相连的话,则证明此对象是不可用的,需要被回收。

那些对象是可以作为 “GC Roots”

  • 虚拟机栈(栈帧中的本地变量表)中引用的对象
  • 本地方法栈(Native 方法)中引用的对象
  • 方法区中类静态属性引用的对象
  • 方法区中常量引用的对象
  • 所有被同步锁持有的对象
  • JNI(Java Native Interface)引用的对象

在可达性分析法中不可达的对象,也并非是“非死不可”的,这时候它们暂时处于“缓刑阶段”,要真正宣告一个对象死亡,至少要经历两次标记过程:

可达性分析法中不可达的对象被第一次标记并且进行一次筛选,筛选的条件是此对象是否有必要执行 finalize 方法。当对象没有覆盖 finalize 方法,或 finalize 方法已经被虚拟机调用过时,虚拟机将这两种情况视为没有必要执行。

被判定为需要执行的对象将会被放在一个队列中进行第二次标记,除非这个对象与引用链上的任何一个对象建立关联,否则就会被真的回收。

注意:Object 类中的 finalize 方法一直被认为是一个糟糕的设计,影响了 Java 语言的安全和 GC 的性能。JDK9 版本及后续版本中各个类中的 finalize 方法会被逐渐弃用移除。

JVM 调优

image.png JVM调优涉及到两个很重要的概念:吞吐量响应时间

  • 吞吐量:用户代码执行时间/(用户代码执行时间+GC执行时间)。
  • 响应时间:整个接口的响应时间(用户代码执行时间+GC执行时间),stw时间越短,响应时间越短。

调优的前提:是为了提升整个项目的性能,需要以当前的业务为背景,选择合理的趋向,确定好是吞吐量还是响应时间为标准。

  • 立足于对当前系统的调查和未来需求预期,选择合理的方案,不应该有经验之谈来确定优先级:吞吐量、响应时间。
  • 如果是吞吐量优先,则选择 ps+po 组合;如果是响应时间优先,在1.8以后选择G1,在1.8之前选择ParNew+CMS组合。
  • 根据实际情况设置升级年龄,最大年龄为15(年轻代s0-s1 -- 老年代)。
  • 需要设定相关的JVM日志参数:
-Xloggc:/path/name-gc-%t.log 
	-XX:+UseGCLogFileRotation #GC文件循环使用
	-XX:NumberOfGCLogs=5      #使用5个GC文件
	-XX:GCLogFileSize=20M     #每个GC文件的大小
	-XX:+PrintGCDetails       #输出GC的详细信息
	-XX:+PrintGCDateStamps    #在输出GC日志的时候,添加时间戳
	-XX:+PrintGCCauses        #在GC日志中不仅包括GC的结果(例如,GC后有多少内存被释放),还包括GC的原因(例如,为什么这次GC被触发)
CPU经常占满

证明有线程长时间占用系统资源不进行释放,需要定位到具体是哪个线程在占用,具体步骤如下(Linux或Mac):

  1. 命令行界面:top (看当前服务器中所有进程(jps命令可以查看当前服务器运行java进程),找到当前cpu使用率最高的进程,获取到对应的pid)
  2. 命令行界面:top -pid pid (查看该进程中的各个线程信息的cpu使用,找到占用cpu高的线程pid)
  3. jstack pid打印它的线程信息

当内存飙高一般都是堆中对象无法回收造成,因为java中的对象大部分存储在堆内存中。其实也就是常见的oom问题(Out Of Memory),确定步骤:

  1. jinfo pid:可以查看当前进行虚拟机的相关信息列举出来
jinfo -flag PrintGCDetails 81251
-XX:-PrintGCDetails
具体的jinfo pid 有问题,后续补充
  1. jstat -gc pid ms,多长毫秒打印一次gc信息,打印信息如下,里面包含gc测试,年轻代/老年代gc信息等
jstat -gc 81251 5000
S0C     S1C      S0U   S1U      EC        EU        OC         OU        MC      MU      CCSC   CCSU      YGC     YGCT    FGC    FGCT     GCT
14336.0 12800.0  0.0   895.8    150528.0  8682.3    162816.0   14846.9   36224.0 33774.2 5248.0 4745.5      7    0.040    2      0.055    0.096
14336.0 12800.0  0.0   895.8    150528.0  8682.3    162816.0   14846.9   36224.0 33774.2 5248.0 4745.5      7    0.040    2      0.055    0.096
  1. jmap -histo pid | head -20,查找当前进程堆中的对象信息,加上管道符后面的信息以后,代表查询对象数量最多的20个
jmap -histo 81251 | head -20
 num     #instances         #bytes  class name
----------------------------------------------
   1:        101197       22885216  [B
   2:        122163       13664288  [C
   3:         90072        2161728  java.lang.String
   4:          6120        1359840  [I
   5:         13078        1150864  java.lang.reflect.Method
   6:          7839         866208  java.lang.Class
   7:         19891         636512  java.util.concurrent.ConcurrentHashMap$Node
   8:         15241         609640  java.util.LinkedHashMap$Entry
   9:          9324         473912  [Ljava.lang.String;
  10:          5627         472848  [Ljava.util.HashMap$Node;
  11:          8327         396264  [Ljava.lang.Object;
  12:         10297         329504  java.io.File
  13:          5474         306544  java.util.LinkedHashMap
  14:          3965         285480  java.lang.reflect.Field
  15:         12418         282544  [Ljava.lang.Class;
  16:          6688         267520  org.springframework.boot.devtools.filewatch.FileSnapshot
  17:         10231         245544  java.lang.StringBuilder
  1. jmap -dump:format=b,file=xxx pid,可以生成堆信息的文件,但是这个命令不建议在生产环境使用,因为当内存较大时,执行该命令会占用大量系统资源,甚至造成卡顿。建议在项目启动时添加下面的命令,在发生oom时自动生成堆信息文件:-XX:+HeapDumpOnOutOfMemory。

多使用第三方工具:阿里的arthas 作为线上 堆信息分析

jdk提供的可视化工具 jvisualvmjconsole 都可以看

G1的常用参数:

-XX:+UseG1 : 使用G1垃圾回收器
-XX:MaxGCPauseMills : GCt停顿时间,该参数也是尽量达到,G1会调整yong区的块数来达到这个值
-XX:+G1HeapRegionSize : 分区大小,范围为1M~32M,必须是2的n次幂,size越大,GC回收间隔越大,但是GC所用时间越长

JVM 内存分析

image.png

JVM内存区域主要分为:

  1. 线程私有(Theard Local Region):程序计数器、虚拟机栈、本地方法区。数据的生命周期和线程一致,跟随用户线程的启动或结束,线程创建或销毁,JVM中的线程和操作系统的本地线程直接映射。
  2. 线程共享(Theard Shared Region):JAVA 堆、方法区。随虚拟机的启动/关闭而创建/销毁。
  3. 直接内存(Direct Memory):不受JVM GC管理

直接内存不是Java 虚拟机中JVM运行时数据区的一部分, 但也会被频繁的使用: 在JDK 1.4引入的NIO提供了基于Channel与Buffer的IO方式, 它可以使用Native函数库直接分配堆外内存, 然后使用DirectByteBuffer对象作为这块内存的引用进行操作(详见: Java I/O 扩展), 这样就避免了在Java堆和Native堆中来回复制数据, 因此在一些场景中可以显著提高性能。

分析:

  • 程序计数器:一块较小的内存空间, 是当前线程所执行的字节码的行号指示器,每条线程都要有一个独立的程序计数器,这类内存也称为“线程私有”的内存。正在执行java方法的话,计数器记录的是虚拟机字节码指令的地址(当前指令的地址)。如果还是Native方法,则为空。这个内存区域是唯一一个在虚拟机中没有规定任何OutOfMemoryError情况的区域。

  • 虚拟机栈:是描述java方法执行的内存模型,每个方法在执行的同时都会创建一个栈帧(Stack Frame)用于存储局部变量表、操作数栈、动态链接、方法出口等信息。每一个方法从调用直至执行完成的过程,就对应着一个栈帧在虚拟机栈中入栈到出栈的过程。栈帧( Frame)是用来存储数据和部分过程结果的数据结构,同时也被用来处理动态链接 (Dynamic Linking)、 方法返回值和异常分派( Dispatch Exception)。 栈帧随着方法调用而创建,随着方法结束而销毁,无论方法是正常完成还是异常完成(抛出了在方法内未被捕获的异常)都算作方法结束。

  • 本地方法区:本地方法区和Java Stack作用类似, 区别是虚拟机栈为执行Java方法服务, 而本地方法栈则为Native方法服务,如果一个VM实现使用C-linkage模型来支持Native调用,那么该栈将会是一个C栈,但HotSpot VM直接就把本地方法栈和虚拟机栈合二为一。

  • Java 堆(Java Heap):是Java 虚拟机JVM运行时数据区中,被线程共享的一块内存区域,创建的对象和数组都保存在Java堆内存中,也是垃圾收集器进行垃圾收集的最重要的内存区域。由于现代VM采用分代收集算法, 因此Java堆从GC的角度还可以细分为: 新生代(Eden区、From Survivor区和To Survivor区)和老年代

  • 方法区(Method Area)/永久代(Permanent Generation):我们常说的永久代, 用于存储被JVM加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。 HotSpot VM把GC分代收集扩展至方法区, 即使用Java堆的永久代来实现方法区,这样HotSpot的垃圾收集器就可以像管理Java堆一样管理这部分内存,而不必为方法区开发专门的内存管理器(永久带的内存回收的主要目标是针对常量池的回收和类型的卸载, 因此收益一般很小)。运行时常量池(Runtime Constant Pool)是方法区的一部分。Class文件中除了有类的版本、字段、方法、接口等描述等信息外,还有一项信息是常量池(Constant Pool Table),用于存放编译期生成的各种字面量和符号引用,这部分内容将在类加载后存放到方法区的运行时常量池中。 Java虚拟机对Class文件的每一部分(自然也包括常量池)的格式都有严格的规定,每一个字节用于存储哪种数据都必须符合规范上的要求,这样才会被虚拟机认可、装载和执行。

分代收集算法

  • 新生代(Young Generation):用来存放新生的对象。一般占据堆的1/3空间。由于频繁创建对象,所以新生代会频繁触发MinorGC进行垃圾回收。新生代又分为 Eden区、ServivorFrom、ServivorTo三个区

  • 老年代(Old Generation):主要存放应用程序中生命周期长的内存对象。老年代的对象比较稳定,所以MajorGC不会频繁执行。在进行MajorGC前一般都先进行了一次MinorGC,使得有新生代的对象晋身入老年代,导致空间不够用时才触发。当无法找到足够大的连续空间分配给新创建的较大对象时也会提前触发一次MajorGC进行垃圾回收腾出空间。MajorGC采用标记清除算法:首先扫描一次所有老年代,标记出存活的对象,然后回收没有标记的对象。MajorGC的耗时比较长,因为要扫描再回收。MajorGC会产生内存碎片,为了减少内存损耗,我们一般需要进行合并或者标记出来方便下次直接分配。当老年代也满了装不下的时候,就会抛出OOM(Out of Memory)异常。

  • 永久代(Permanent Generation):指内存的永久保存区域,主要存放Class和Meta(元数据)的信息,Class在被加载的时候被放入永久区域,它和存放实例的区域不同,GC不会在主程序运行期对永久区域进行清理。所以这也导致了永久代的区域会随着加载的Class的增多而胀满,最终抛出OOM异常

  • 元数据区(Metaspace): 在Java8中,永久代已经被移除,被一个称为“元数据区”(元空间)的区域所取代。元空间的本质和永久代类似,元空间与永久代之间最大的区别在于:元空间并不在虚拟机中,而是使用本地内存。因此,默认情况下,元空间的大小仅受本地内存限制。类的元数据放入 native memory, 字符串池和类的静态变量放入java堆中,这样可以加载多少类的元数据就不再由MaxPermSize控制, 而由系统的实际可用空间来控制。

JMM 内存模型

Java 虚拟机规范中试图定义一种 Java 内存模型(Java Memory Model,简称 JMM)来屏蔽掉各种硬件和操作系统的内存访问差异,以实现让 Java 程序在各种平台下都能达到一致的内存访问效果,不必因为不同平台上的物理机的内存模型的差异,对各平台定制化开发程序

Java 的内存模型是并发编程领域的一次重要创新,之后 C++、C#、Golang 等高级语言都开始支持内存模型。

为了解决什么问题?

  1. 缓存一致性问题
  2. 代码乱序执行优化问题
    重排序分为两类:编译期重排序和运行期重排序,分别对应编译时和运行时环境。
    单线程环境下不能改变程序运行的结果。即时编译器(和处理器)需要保证程序能够遵守 as-if-serial (串行) 属性。也就是说,在单线程情况下,要给程序一个顺序执行的假象,即使经过重排序的执行结果要与顺序执行的结果保持一致
    存在数据依赖关系的不允许重排序。

image.png

  • lock (锁定):作用于主内存的变量,把一个变量标识为一条线程独占的状态。

  • unlock (解锁):作用于主内存的变量,把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定。

  • read (读取):作用于主内存的变量,把一个变量的值从主内存传输到线程的工作内存中,以便随后的 load 动作使用。

  • load (载入):作用于工作内存的变量,把 read 操作从主内存中得到的变量值放入工作内存的变量副本中。

  • use (使用):作用于工作内存的变量,把工作内存中一个变量的值传递给执行引擎,每当虚拟机遇到一个需要使用到变量的值的字节码指令时就会执行这个操作。

  • assign (赋值):作用于工作内存的变量,把一个从执行引擎接收到的值赋给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作。

  • store (存储):作用于工作内存的变量,把工作内存中一个变量的值传送到主内存中,以便随后 write 操作使用。

  • write (写入):作用于主内存的变量,把 Store 操作从工作内存中得到的变量的值放入主内存的变量中。

思考几个问题:
当线程write的时候需要先lock,如果当前变量已经被lock了,那么这个线程执行什么操作?
当前变量被lock了,那么read是否可以进行操作呢?

为了保证内存间数据一致性,JMM 中规定需要满足以下规则:

  1. 如果要把一个变量从主内存中复制到工作内存,就需要按顺序的执行 read 和 load 操作,如果把变量从工作内存中同步回主内存中,就要按顺序的执行 store 和 write 操作。但 Java 内存模型只要求上述操作必须按顺序执行,而没有保证必须是连续执行。
  2. 不允许 read 和 load、store 和 write 操作之一单独出现。
  3. 不允许一个线程丢弃它的最近 assign 的操作,即变量在工作内存中改变了之后必须同步到主内存中。
  4. 不允许一个线程无原因的(没有发生过任何 assign 操作)把数据从工作内存同步回主内存中。
  5. 一个新的变量只能在主内存中诞生,不允许在工作内存中直接使用一个未被初始化(load 或 assign)的变量。即对一个变量实施 use 和 store 操作之前,必须先执行过了 load 或 assign 操作。
  6. 一个变量在同一个时刻只允许一条线程对其进行 lock 操作,但 lock 操作可以被同一条线程重复执行多次,多次执行 lock 后,只有执行相同次数的 unlock 操作,变量才会被解锁。所以 lock 和 unlock 必须成对出现。
  7. 如果对一个变量执行 lock 操作,将会清空工作内存中此变量的值,在执行引擎使用这个变量前需要重新执行 load 或 assign 操作初始化变量的值。
  8. 如果一个变量事先没有被 lock 操作锁定,则不允许对它执行 unlock 操作;也不允许去 unlock 一个被其他线程锁定的变量。
  9. 对一个变量执行 unlock 操作之前,必须先把此变量同步到主内存中(执行 store 和 write 操作)。

1,2 -> 工作内存中的共享变量作为主内存的副本,主内存变量的值同步到工作内存需要 read 和 load 一起使用。

3,4 -> 由于工作内存中的共享变量是主内存的副本,为保证数据一致性,当工作内存中的变量被字节码引擎重新赋值,必须同步回主内存。如果工作内存的变量没有被更新,不允许无原因同步回主内存。

5 -> 由于工作内存中的共享变量是主内存的副本,必须从主内存诞生。

6,7,8,9 -> 为了并发情况下安全使用变量,线程可以基于 lock 操作独占主内存中的变量,其他线程不允许使用或 unlock 该变量,直到变量被线程 unlock。

JMM需要依靠什么来保障呢:

  • 原子性 (Atomicity)
  • 可见性 (Visibility)
  • 有序性 (Ordering)

对于同步方法,同步块(synchronized 关键字修饰)以及 volatile 修饰字段的操作仍维持相对有序。

happens-before 关系的分析: 前面一个操作的结果对后续操作是可见的
单线程:字节码必然是顺序执行,不存在数据一致性问题。
多线程:由于每个线程有共享变量的副本,如果没有对共享变量做同步处理,线程 1 更新执行操作 A 共享变量的值之后,线程 2 开始执行操作 B,此时操作 A 产生的结果对操作 B 不一定可见。

JMM 对 happens-before 的支持:

  • 程序次序规则:一个线程内,按照代码顺序,书写在前面的操作 happens-before 书写在后面的操作。
  • 锁定规则:一个 unLock 操作 happens-before 前面有对同一个锁的 lock 操作。
  • volatile 变量规则:对一个变量的写操作 happens-before 后面对这个变量的读操作。
  • 传递规则:如果操作 A happens-before 操作 B,而操作 B 又 happens-before 操作 C,则可以得出操作 A happens-before 操作 C。
  • 线程启动规则:Thread 对象的 start() 方法 happens-before 此线程的每个一个动作。
  • 线程中断规则:对线程 interrupt() 方法的调用 happens-before 被中断线程的代码检测到中断事件的发生。
  • 线程终结规则:线程中所有的操作都 happens-before 线程的终止检测,我们可以通过 Thread.join() 方法结束、Thread.isAlive() 的返回值手段检测到线程已经终止执行。
  • 对象终结规则:一个对象的初始化完成 happens-before 它的 finalize() 方法的开始。

Java 中如何保证底层操作的有序性和可见性:

内存屏障
内存屏障是被插入两个 CPU 指令之间的一种指令,用来禁止处理器指令发生重排序(像屏障一样),从而保障有序性的。另外,为了达到屏障的效果,它也会使处理器写入、读取值之前,将主内存的值写入高速缓存,清空无效队列,从而保障可见性。

常见有 4 种屏障:

  • LoadLoad 屏障
Load1;
LoadLoad; # 屏障
Load2;
# 保障了在Load2读取之前, Load1 要读取的数据已经被读取完毕。
  • StoreStore 屏障
Store1;
StoreStore; # 屏障
Store2;
# 保障了在Store2写入之前, Store1 要写入的数据已经被写入完毕。
  • LoadStore 屏障
Load1;
LoadStore; # 屏障
Store2;
# 保障了在 Store2 写入之前, Load1 要读取的数据已经被读取完毕。
  • StoreLoad 屏障
Store1;
StoreLoad; # 屏障
Load2;
# 保障了在Load2读取之前, Store1 要写入的数据已经被写入完毕。

StoreLoad 的开销是四种屏障中最大的(冲刷写缓冲器,清空无效化队列),在大多数处理器的实现中,这个屏障是个万能屏障,兼具其他三种内存屏障的功能。

Volatile分析

作用:

  • 保证可见性

线程对变量进行修改之后,要立刻回写到主内存。
线程对变量读取的时候,要从主内存中读,而不是从线程的工作内存,但是如果多个线程同时把更新后的变量值同时刷新回主内存,可能导致得到的值不是预期结果。

  • 禁止进行指令重排序

image.png

  • 在每个 volatile 写操作的前面插入一个 StoreStore 屏障。该屏障除了保证了屏障之前的写操作和该屏障之后的写操作不能重排序,还会保证了 volatile 写操作之前,任何的读写操作都会先于 volatile 被提交。

  • 在每个 volatile 写操作的后面插入一个 StoreLoad 屏障。该屏障除了使 volatile 写操作不会与之后的读操作重排序外,还会刷新处理器缓存,使 volatile 变量的写更新对其他线程可见。

  • 在每个 volatile 读操作的后面插入一个 LoadLoad 屏障。该屏障除了使 volatile 读操作不会与之前的写操作发生重排序外,还会刷新处理器缓存,使 volatile 变量读取的为最新值。

  • 在每个 volatile 读操作的后面插入一个 LoadStore 屏障。该屏障除了禁止了 volatile 读操作与其之后的任何写操作进行重排序,还会刷新处理器缓存,使其他线程 volatile 变量的写更新对 volatile 读操作的线程可见。

“一次写入,到处读取”,某一线程负责更新变量,其他线程只读取变量(不更新变量),并根据变量的新值执行相应逻辑。

long 和 double 型变量的特殊规则:

两者是基本数据类型中唯一的64位的数据类型。

在模型中特别定义相对宽松的规定:允许虚拟机将没有被 volatile 修饰的 64 位数据的读写操作分为 2 次 32 位的操作来进行。 也就是说虚拟机可选择不保证 64 位数据类型的 load、store、read 和 write 这 4 个操作的原子性

由于这种非原子性,有可能导致其他线程读到同步未完成的“32 位的半个变量”的值。

商用虚拟机都选择把 64 位数据的读写操作作为原子操作来对待,因此我们在编写代码时一般不需要把用到的 long 和 double 变量专门声明为 volatile。但是涉及到免费的OpenJDK需要核查清楚。

面向对象编程OOP的拓展

  • 面向过程编程(Procedure-Oriented Programming,简称 POP):这是一种早期的编程范式,主要关注程序的流程和过程。在面向过程编程中,算法是程序的核心,数据被视为程序的辅助元素。

  • 面向对象编程(Object-Oriented Programming,简称 OOP):这是一种将数据和方法封装在对象中的编程范式。在面向对象编程中,程序是由对象和它们之间的交互组成的,通过定义类和继承来实现代码的重用和扩展。

  • 函数式编程(Functional Programming,简称 FP):这是一种将函数视为第一类对象的编程范式。在函数式编程中,程序是由函数组成的,而函数是可变的,可以作为参数传递给其他函数,也可以作为结果返回。

  • 逻辑式编程(Logic Programming,简称 LP):这是一种通过定义规则和事实来解决问题的编程范式。在逻辑式编程中,程序是由规则和事实组成的,通过推理和证明来解决特定问题。

  • 命令式编程(Imperative Programming,简称 IP):这是一种通过定义一系列步骤来解决问题的编程范式。在命令式编程中,程序是由一系列语句组成的,通过明确指定每一步的操作来解决问题。

  • 响应式编程(Reactive Programming,简称 RP):这是一种处理异步事件流的编程范式。在响应式编程中,程序是由一系列事件驱动的,事件触发一系列的计算和操作。

  • 并发编程(Concurrent Programming,简称 CP):这是一种处理并发操作的编程范式。在并发编程中,程序是由多个并发执行的线程或进程组成的,需要处理并发控制、同步和通信等问题。


  1. 面向过程编程(POP):虽然不像上述范式那样具有代表性,但是许多语言都支持这种编程范式,如 C、Assembly、Bash 等。
  2. 面向对象编程(OOP):代表性的编程语言有 Java、C++、C#、Python、Ruby 等。
  3. 函数式编程(FP):代表性的编程语言有 Haskell、Erlang、Lisp、Clojure、Rust 等。
  4. 逻辑式编程(LP):代表性的编程语言有 Prolog 等。
  5. 响应式编程(RP):代表性的编程语言有 JavaScript、C#(在 ReactiveX 中)、Scala(在 RxScala 中)等。
  6. 并发编程(CP):代表性的编程语言有 Java、C++(配合 Boost)、Python、Erlang 等。
面向对象的五大基本原则
  1. 单一职责原则(Single Responsibility Principle,简称 SRP):一个类只负责一个职责,职责不应该交织在一起。
  2. 开放封闭原则(Open Closed Principle,简称 OCP):对扩展开放,对修改封闭。即在设计时,应该将程序的功能添加到系统中,而不是修改已有的代码。
  3. 里氏替换原则(Liskov Substitution Principle,简称 LSP):子类必须能够替换其父类,即在程序中应该能够使用父类的对象而不需要修改代码。
  4. 接口隔离原则(Interface Segregation Principle,简称 ISP):使用多个小的专门的接口,而不要使用一个大的总接口。
  5. 依赖倒置原则(Dependency Inversion Principle,简称 DIP):程序要依赖抽象接口,而不是具体的实现。
  6. KISS原则(Keep it Simple and Stupid Principle, KISS原则):保持代码可读和可维护的原则。
  7. YAGNI原则(You Ai Not Gonna Need It Principle,YAGNI原则):避免过度设计的原则,不用去设计用不到的功能和不用去编写用不到的代码。
  8. DRY原则(Do Not Repeat Yourself Principle,DRY原则): 减少编写重复的代码的原则,提高代码复用。

总结

文章借鉴来源:
苏三的Java突击队:www.susan.net.cn/home.html
JavaGuide的学习网站:javaguide.cn/
mazhilin的文章:www.imooc.com/article/det…
未知博主的文章:www.uml.org.cn/j2ee/201812…

这篇文章主要就是自己的学习和积累,拓展了好多好多,但是很多都只是浅尝辄止,没有系统性的串联和关联起来,到了哪一步然后有涉及就查资料借鉴过来,或者文章很好就收藏过来,所以知识很碎片化。但是从这个大概可以了解到Java有什么,是什么,以及可以做什么。

也留下一些问题:
什么是as-if-serial(串行),以及拓展和关联的概念有哪些?
happens-before也没有很清楚的理解,期待后面的深入了啊。