一. 缓存雪崩

image-20220406193747187

“雪崩来临的时候没有一片雪花是无辜的”。缓存雪崩就是大范围甚至于整个redis提供的缓存服务不可用了,进而导致所有的请求都直接到了数据库,甚至于击垮整个服务链路。造成整个服务不可用。

出现原因:

  1. 给缓存设置了过期时间,且大范围的缓存数据的过期时间一致。

  2. redis服务宕机。

解决方案:

提前预案:

  1. 给redis过期时间加随机值预防大面积的缓存同时过期失效。
  2. redis集群高可用可用,哨兵机制。

兜底方案:
3. 服务熔断,服务降级。监控到缓存服务不可用时直接返回,或者限制流量直接请求到数据库层。

二. 缓存击穿

相交于缓存雪崩大范围或整体缓存不可用缓存击穿则是指某个热点key过期,导致的缓存失效。常常是一部分热点数据,如秒杀产品的库存数据。

出现原因:
热点数据过期,或者被其他手段删除。

解决方案:

  1. 对于热点数据缓存时不设置过期时间。

  2. 第一个请求发现热点数据不在redis缓存中,可以先阻塞其他请求,等到第一个请求将数据库数据读出来并缓存到redis后再唤醒其他请求从缓存服务中读取热点数据。



三. 缓存穿透

缓存穿透则是另外一个层面,指的时请求所访问的数据既不在缓存中,也不在数据库中。如果应用持续有大量请求访问数据,就会同时给缓存和数据库带来巨大压力。

出现原因:

  1. 业务层误操作访问到了不会存在的数据。

  2. 恶意请求攻击

解决方案:

  1. 第一个请求发现热点数据不在redis缓存中和数据库中,可以先阻塞其他请求,缓存一个缺省值返回。
  2. 利用redis提供的布隆过滤器。
  3. 前端有效值校验。

四. 总结

缓存雪崩缓存击穿均属于缓存失效的一种异常缓存雪崩影响范围大于缓存击穿。缓存穿透则是数据本身就不在整个数据存储层。

Comment and share

纠葛的问题:REST和RPC的对比?

思想上,概念上,使用范围 RPC和REST都完全不一样,本质上并不是同一类东西,只有部分功能重合。REST和RPC在思想上存在差异的核心是:抽象目标不一样,也就是面向资源的编程思想和面向过程的编程思想之间的差异。
REST:本质上不是一种协议,而是一种风格。

理解REST

REST 概念的提出来自于罗伊·菲尔丁(Roy Fielding)在 2000 年发表的博士论文:《Architectural Styles and the Design of Network-based Sorftware Architectures》

REST全称为:Representational State Transfer 即表征状态转移。

为何常常使用HTTP搭配REST

REST实际上是HTT(Hyper Text Transfer,超文本传输)的进一步抽象,就想是接口和实现类之间的关系。(即REST是接口,HTT是实现)

  • Representation 表征 (对Resource的转换):

    浏览器向客户端请求资源的HTML格式,那么返回的这个HTML我们就可以称之为:表征。也可以理解为MVC模式中的:表示层。

  • State 状态:

    特定语境中产生的上下文信息称之为“状态”。注意Stateful 和 Stateless是针对的服务端没有记录上下文。浏览器每次携带上下文来请求服务端,比如:JWT技术

  • Transfer 转移:

    服务端通过浏览器提供的信息,由当前的表示层转移到新的标识层,这就被成为:表征状态转移。

如何评价一个系统是否符合RESTful ?

Fielding 认为,一套理想的、完全满足 REST 的系统应该满足以下六个特征。

  1. 服务端与客户端分离(Client-Server)

  2. 无状态(Stateless)

    REST希望服务器不能负责维护状态,每一次从客户端发送的请求中,应该包括所有必要的上下文信息,会话信息也由客户端保存维护。服务器端依据客户端传递的状态信息来进行业务处理,并且驱动整个应用的状态变迁。提升了系统的可见性、可靠性和可伸缩性(大多数系统达不到这个要求,越复杂,越大型的系统越是如此。)

  3. 可缓存(Cacheability)

    REST希望客户端和中间的通讯传递者(代理)可以将部分服务端应答缓存起来。通过良好的缓存机制,减少客户端和服务端的交互。

  4. 分层系统(Layered System)

    不是传统的MVC这样的分层。而是指客户端一般不需要知道是否直接链接到了最终的服务器,或者是链接到了路径上的中间服务器。中间服务器可以通过负载均衡和共享缓存机制,提高系统的可扩展性。这样也便于缓存,伸缩和安全策略的部署。

  5. 统一接口(Uniform Interface)

    REST希望开发者面向资源编程,希望软件系统设计重点放放在抽象系统有哪些资源上,而不是抽象系统该有哪些行为上。

    举例:

    几乎每个系统都会有登录和注销功能,如果你登录对应的是login() 注销对应于loginout()。那么如果面向资源可以理解成为:登录是PUT session 注销是DELETE session,这样你只需要设计一种“session”资源即可。查询登录用户使用GET session。

    Felding给出三个建议:

    1. 系统要做到每次请求中都包含资源的ID,所有操作都通过ID完成。
    2. 每个资源都应该有自描述信息。
    3. 通过超文本来驱动应用状态的转移。
  6. 按需代码(Code-On-Demand)

RESTful带来的优点

  1. 降低了服务接口的学习成本

    它把资源的标准操作都映射到了标准的HTTP方法上(GET,PUT,POST,DELETE)对每个资源的语义一致。无需额外定义。

  2. 资源天然具有集合与层次结构

    1
    GET /book/{bookId}/chapter/{chapterNumber}

    天热的集合与层次关系。

  3. RESTful绑定于HTTP协议

    面向资源编程并不是必须构筑在 HTTP 之上。

    但是HTTP 协议已经有效运作了 30 年,与其相关的技术基础设施已是千锤百炼,无比成熟。而它的坏处自然就是,当你想去考虑那些 HTTP 不提供的特性时,就束手无策了。

RESTful缺点

  1. 面向资源只适合做CRUD,只有面向过程,面向对象编程才能处理真正复杂的业务逻辑。

  2. REST与HTTP完全绑定,不适用于要求高性能传输场景当中。

    面向资源编程与协议无关,但是 REST(特指 Fielding 论文中所定义的 REST,而不是泛指面向资源的思想)的确依赖着 HTTP 协议的标准方法、状态码和协议头等各个方面。

  3. REST 不利于事务支持。

  4. REST 没有传输可靠性支持。

    应对传输可靠性最简单粗暴的做法,就是把消息再重发一遍。这种简单处理能够成立的前提,是服务具有幂等性(Idempotency),也就是说服务被重复执行多次的效果与执行一次是相等的。

  5. REST 缺乏对资源进行“部分”和“批量”的处理能力。

    就是缺少对资源的“部分”操作的支持。要解决批量操作这类问题,目前一种从理论上看还比较优秀的解决方案是GraphQL(但实际使用人数并不多)。GraphQL 是由 Facebook 提出并开源的一种面向资源 API 的数据查询语言。它和 SQL 一样,挂了个“查询语言”的名字,但其实 CRUD 都能做。

参考链接

周志明的软件架构课:https://time.geekbang.org/column/article/317164

李锟谈 Fielding 博士 REST 论文中文版发布:https://www.infoq.cn/article/2007/07/dlee-fielding-rest/

Fielding论文:https://www.ics.uci.edu/~fielding/pubs/dissertation/top.htm

REST和RPC对比结论:

无论是思想上,概念上,还是使用范围上,REST和RPC都不完全一样,本质上并不是同一个类型的东西。充其量是相似,在应用中会存在重合功能。

  1. 思想上差异:抽象目标不一样,REST是面向资源的编程思想,RPC是面向过程。
  2. 概念上:REST并不是一种远程服务调用协议,也可以说它就不是一种协议,只是一种风格。RPC是作为一种远程调用协议的存在。
  3. 试用范围:REST和RPC作为主流的两种远程调用方式,在使用上确实有重合之处。

Comment and share

众所周知Java作为高级编程语言是不需要程序员去手动释放内存垃圾(垃圾指的是死亡的对象所占据的堆空间),JVM会替我们完成。那么JVM是如何识别出来哪些是需要被回收的对象?JVM是如何整理内存空间?JVM GC(Garbage Collection)过程和应用程序的线程有哪些相互影响?

如何识别垃圾对象-识别算法

程序员不需要手动编码精准释放不用的对象,那么JVM是如何做到自动识别的呢?

  • 引用计数法(reference counting)

    做法是为每个对象添加一个引用计数器,用来统计指向该对象的引用个数。一旦一个对象的引用计数器为0,则说明该对象已经死亡,它所占用的堆空间可以被回收。

    使用此类算法的有 Python、Objective-C、Per l等。

    优点:

    ​ 算法简单,容易实现:如果有一个引用,被赋值为某一对象,那么将该对象的引用计数器 +1。如果一个指向某一对象的引用,被赋值为其他值,那么将该对象的引用计数器 -1。也就是说,我们需要截获所有的引用更新操作,并且相应地增减目标对象的引用计数器。

    缺点:

    1. 需要额外的空间存储计数器,和繁琐的计数器更新。

    2. 无法解决循环引用造成内存泄漏。

      GC roots path

      对象 a 与 b 相互引用,除此之外没有其他引用指向 a 或者 b。在这种情况下,a 和 b 实际上已经死了,但由于它们的引用计数器皆不为 0,在引用计数法的心中,这两个对象还活着。因此,这些循环引用对象所占据的空间将不可回收,从而造成了内存泄露。

    • 可达性分析(JVM采用)

      将一系列GC Roots作为初始的存活对象合集(Live Set),然后从该集合出发,探索所有能被该集合引用到的对象,将其加入该集合中,这一过程也被称作标记(Mark)。最终未被探索到的对象便是死亡的,是可以回收的。

      GC Roots可以理解为由堆外指向堆内的引用,一般包括但不仅限于以下:

      1. Java方法栈中的布局变量。Java内存布局
      2. 已加载类的静态变量。
      3. 本地方法栈中Native方法引用的对象
      4. 虚拟机栈(栈帧中本地变量表)中引用的对象
      5. 已经启动且未停职的线程。

回收算法-垃圾回收器的工作原理

由上一步的识别算法找到需要被回收的对象后,接下来就是需要将识别出来的垃圾对象所占用的内存空间进行回收,以便于进行再次利用。回收的算法主要包括:

基础算法:

  1. 标记-清除算法
  2. 标记-压缩算法
  3. 标记-复制算法

改进算法(由上边的算法演进而来):

  1. 分代算法
  2. 增量算法
  3. 并发算法

标记-清除(Sweep)

把死亡对象所占据的内存标记为空闲内存,并记录在一个空闲列表(Free List)中。当需要新建对象时,内存管理便会从空闲列表中寻找空闲内存,并划分给新建对象。

img
优点:

原理,实现比较简单

缺点
  1. 造成内存碎片,由于Java虚拟机的堆中对象必须是连续分布,因此可能出现空闲内存足够,但是无法分配的极端情况。
  2. 分配效率极低,如果是连续的内存空间我们可以通过指针加法(Pointer bumping)来做分配。而对于空闲列表,java虚拟机则需要逐个访问列表中的项,来查找能够放入新建对象的空闲内存。

标记-压缩(Compact)

把存活的对象挪到内存的起始位置。

img
优点

这样做能够开辟出连续的空间,解决内存碎片化问题。

缺点

压缩算法的性能开销比较大。

标记-复制(Copy)

把内存区域划分为两份,分别使用fromto来维护,并且只是用from指针指向的内存区域来分配内存。当发生垃圾回收时,便把存活对象复制到to指针指向的内存区域中,并且交换from指针和to指针指向的内容。

img
优点:
  • 不会发生碎片化
  • 优秀的吞吐率
  • 可实现高速分配
  • 良好的locality
缺点:

由于to指针指向的区域始终是空闲的,所以空间利用率极低。

分代算法(Generation)

分代算法基于这样一种假说(Generational Hypothesis):绝大多数对象都是朝生夕死的。分代算法把对象分为几代,新生成的对象称之为:新生代,负责对新生代进行垃圾回收的叫minor GC。到达一定年龄的对象则称之为老年代对象,面向老年代GC的叫major GC。新生代到老年代的过程称之为:晋升(Promotion)注:代数并不是划分的越多越好,虽然按照分代假说,如果分代数越多,最后抵达老年代的对象就越少,在老年代对象上消耗的垃圾回收的时间就越少,但分代数增多会带来其他的开销,综合来看,代数划分为 2 代或者 3 代是最好的。

分代算法由于其普适性,已经被大多数的垃圾回收器采用(ZGC 目前不支持,但也在规划中了)。

增量算法(Increment)

增量算法对基础算法的改进主要体现在该算法通过并发的方式,降低了 STW 的时间。下图是增量算法和基础的标记-清除算法在执行时间线上的对比,可以看到,增量算法的核心思想是:通过 GC 和应用程序交替执行的方式,来控制应用程序的最大暂停时间。

image.png

增量算法的「增量」部分,主要有「增量更新(Incremental Update)」和「增量拷贝(Incremental Copying)」两种,前者主要是做「标记」增量,后者是在做「复制」增量。

并发算法(Concurrent)

广义上的并发算法指的是在 GC 过程中存在并发阶段的算法,如 G1 中存在并发标记阶段,可将其整个算法视为并发算法。

狭义上的并发垃圾回收算法是以基础的标记-复制算法为基础,在各个阶段增加了并发操作实现的。与复制算法的3个阶段相对应,分为并发标记(mark)、并发转移(relocate)和并发重定位(remap):

1)并发标记

从 GC Roots 出发,使用遍历算法对对象的成员变量进行标记。同样的,并发标记也需要解决标记过程中引用关系变化导致的漏标记问题,这一点通过写屏障实现;

(2)并发转移

根据并发标记后的结果生成转移集合,把活跃对象转移(复制)到新的内存上,原来的内存空间可以回收,转移过程中会涉及到应用线程访问待转移对象的情况,一般的解决思路是加上读屏障,在完成转移任务后,再访问对象;

(3)并发重定位

对象转移后其内存地址发生了变化,所有指向对象老地址的指针都要修正到新的地址上,这一步一般通过读屏障来实现。

JVM堆空间划分

JVM首先采用分代算法将堆空间划分如下:

  1. 新生代
    • 新生代又划分为一个eden区两个大小相同的survivor区
  2. 老年代

默认情况下Java虚拟机采用的是动态分配策略,使用参数:XX:+UsePSAdaptiveSurvivorSizePolicy 据生成对象的速率,以及 Survivor 区的使用情况动态调整 Eden 区和 Survivor 区的比例。

也可以通过参数 -XX:SurvivorRatio 来固定这个比例。但是需要注意的是,其中一个 Survivor 区会一直为空,因此比例越低浪费的堆空间将越高。

img

通常来讲(除过逃逸分析是在栈上分配):new 一个对象需要在Eden区划分一片内存作为对象的存储空间。由于堆空间是线程共享的,因此直接在这里边划空间是需要进行同步的。否则,将有可能出现两个对象共用一段内存的事故。为了解决多个线程在同时创建时可能造成的占用内存冲突引入了TLAB(Thread Local Allocation Buffer)技术

具体来说,每个线程可以向 Java 虚拟机申请一段连续的内存,比如 2048 字节,作为线程私有的 TLAB。这个操作需要加锁,线程需要维护两个指针(实际上可能更多,但重要也就两个),一个指向 TLAB 中空余内存的起始位置,一个则指向 TLAB 末尾。

Minor GC

  1. 当 Eden 区的空间耗尽了怎么办?这个时候 Java 虚拟机便会触发一次 Minor GC,来收集新生代的垃圾。

  2. 存活下来的对象,则会被送到 Survivor 区。前面提到,新生代共有两个 Survivor 区,我们分别用 from 和 to 来指代。其中 to 指向的 Survivior 区是空的。当发生 Minor GC 时,Eden 区和 from 指向的 Survivor 区中的存活对象会被复制到 to 指向的 Survivor 区中,然后交换 from 和 to 指针,以保证下一次 Minor GC 时,to 指向的 Survivor 区还是空的。

  3. Java 虚拟机会记录 Survivor 区中的对象一共被来回复制了几次。如果一个对象被复制的次数为 15(对应虚拟机参数 -XX:+MaxTenuringThreshold),那么该对象将被晋升(promote)至老年代。

  4. 如果单个 Survivor 区已经被占用了 50%(对应虚拟机参数 -XX:TargetSurvivorRatio),那么较高复制次数的对象也会被晋升至老年代。

  5. 当发生 Minor GC 时,我们应用了标记 - 复制算法,将 Survivor 区中的老存活对象晋升到老年代,然后将剩下的存活对象和 Eden 区的存活对象复制到另一个 Survivor 区中。

    理想情况下,Eden 区中的对象基本都死亡了,那么需要复制的数据将非常少,因此采用这种标记 - 复制算法的效果极好。Minor GC 的另外一个好处是不用对整个堆进行垃圾回收。但是,它却有一个问题,那就是老年代的对象可能引用新生代的对象。也就是说,在标记存活对象的时候,我们需要扫描老年代中的对象。如果该对象拥有对新生代对象的引用,那么这个引用也会被作为 GC Roots。

    这样一来,岂不是又做了一次全堆扫描呢?

卡表

HotSpot 给出的解决方案是一项叫做卡表(Card Table)的技术。

  1. 该技术将整个堆划分为一个个大小为 512 字节的卡,并且维护一个卡表,用来存储每张卡的一个标识位。

  2. 这个标识位代表对应的卡是否可能存有指向新生代对象的引用。如果可能存在,那么我们就认为这张卡是脏的。

  3. 在进行 Minor GC 的时候,我们便可以不用扫描整个老年代,而是在卡表中寻找脏卡,并将脏卡中的对象加入到 Minor GC 的 GC Roots 里。当完成所有脏卡的扫描之后,Java 虚拟机便会将所有脏卡的标识位清零。

  4. 由于 Minor GC 伴随着存活对象的复制,而复制需要更新指向该对象的引用。因此,在更新引用的同时,我们又会设置引用所在的卡的标识位。这个时候,我们可以确保脏卡中必定包含指向新生代对象的引用。

  5. 在 Minor GC 之前,我们并不能确保脏卡中包含指向新生代对象的引用。其原因和如何设置卡的标识位有关。

  6. 首先,如果想要保证每个可能有指向新生代对象引用的卡都被标记为脏卡,那么 Java 虚拟机需要截获每个引用型实例变量的写操作,并作出对应的写标识位操作。这个操作在解释执行器中比较容易实现。

  7. 但是在即时编译器生成的机器码中,则需要插入额外的逻辑。这也就是所谓的写屏障(write barrier,注意不要和 volatile 字段的写屏障混淆)。

  8. 写屏障需要尽可能地保持简洁。这是因为我们并不希望在每条引用型实例变量的写指令后跟着一大串注入的指令。因此,写屏障并不会判断更新后的引用是否指向新生代中的对象,而是宁可错杀,不可放过,一律当成可能指向新生代对象的引用。

  9. 这么一来,写屏障便可精简为下面的伪代码[1]。这里右移 9 位相当于除以 512,Java 虚拟机便是通过这种方式来从地址映射到卡表中的索引的。最终,这段代码会被编译成一条移位指令和一条存储指令。

    1
    CARD_TABLE [this address >> 9] = DIRTY;
  10. 虽然写屏障不可避免地带来一些开销,但是它能够加大 Minor GC 的吞吐率( 应用运行时间 /(应用运行时间 + 垃圾回收时间) )。总的来说还是值得的。

  11. 不过,在高并发环境下,写屏障又带来了虚共享(false sharing)问题[2]。在介绍对象内存布局中我曾提到虚共享问题,讲的是几个 volatile 字段出现在同一缓存行里造成的虚共享。这里的虚共享则是卡表中不同卡的标识位之间的虚共享问题。

  12. 在 HotSpot 中,卡表是通过 byte 数组来实现的。对于一个 64 字节的缓存行来说,如果用它来加载部分卡表,那么它将对应 64 张卡,也就是 32KB 的内存。如果同时有两个 Java 线程,在这 32KB 内存中进行引用更新操作,那么也将造成存储卡表的同一部分的缓存行的写回、无效化或者同步操作,因而间接影响程序性能。为此,HotSpot 引入了一个新的参数 -XX:+UseCondCardMark,来尽量减少写卡表的操作。其伪代码如下所示:

    1
    2
    3

    if (CARD_TABLE [this address >> 9] != DIRTY)
    CARD_TABLE [this address >> 9] = DIRTY;

垃圾回收器

1. Serial收集器

Serial收集器是最基础、历史最悠久的收集器,曾经(在JDK 1.3.1之前)是HotSpot虚拟机新生代收集器的唯一选择。这个收集器是一个单线程工作的收集器,但它的“单线 程”的意义并不仅仅是说明它只会使用一个处理器或一条收集线程去完成垃圾收集工作,更重要的是强调在它进行垃圾收集时,必须暂停其他所有工作线程,直到它收集结束。

Serial/Serial Old收 集器的运行过程如下:

image-20220630213504099

2. ParNew收集器

ParNew收集器实质上是Serial收集器的多线程并行版本,除了同时使用多条线程进行垃圾收集之外,其余的行为包括Serial收集器可用的所有控制参数(例如:-XX:SurvivorRatio、-XX:PretenureSizeThreshold、-XX:HandlePromotionFailure等)、收集算法、Stop The World、对象分配规则、回收策略等都与Serial收集器完全一致,在实现上这两种收集器也共用了相当多的代码。

ParNew收集器的工作过程如图所示:

image-20220630213452053

3. Parallel Scavenge收集器

Parallel Scavenge收集器也是一款新生代收集器,它同样是基于标记-复制算法实现的收集器,也是能够并行收集的多线程收集器

Parallel Scavenge收集器的目标则是达到一个可控制的吞吐量(Throughput)。由于与吞吐量关系密切,Parallel Scavenge收集器也经常被称作“吞吐量优先收集器”。

Parallel Scavenge收集器提供了两个参数用于精确控制吞吐量,分别是控制最大垃圾收集停顿时间的-XX:MaxGCPauseMillis参数以及直接设置吞吐量大小的-XX:GCTimeRatio参数。

4. Serial Old收集器

Serial Old是Serial收集器的老年代版本,它同样是一个单线程收集器,使用标记-整理算法。这个收集器的主要意义也是供客户端模式下的HotSpot虚拟机使用。如果在服务端模式下,它也可能有两种用途:一种是在JDK 5以及之前的版本中与Parallel Scavenge收集器搭配使用,另外一种就是作为CMS 收集器发生失败时的后备预案,在并发收集发生Concurrent Mode Failure时使用。这两点都将在后面的内容中继续讲解。

Serial Old收集器的工作过程如图所示。

image-20220630213437080

5. Parallel Old收集器

Parallel Old是Parallel Scavenge收集器的老年代版本,支持多线程并发收集,基于标记-整理算法实现。这个收集器是直到JDK 6时才开始提供的。Parallel Old收集器的工作过程如图所示。

image-20220630213424387

6. CMS收集器

CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器。目前很大一部分的Java应用集中在互联网网站或者基于浏览器的B/S系统的服务端上,这类应用通常都会较为关注服务的响应速度,希望系统停顿时间尽可能短,以给用户带来良好的交互体验。CMS收集器就非常符合这类应用的需求。

Concurrent Mark Sweep收集器运行过程如图:

image-20220630213407564

7. Garbage First(G1)收集器

G1是一款主要面向服务端应用的垃圾收集器,是目前垃圾回收器的前沿成果。HotSpot开发团队最初赋予它的期望是(在比较长期的)未来可以替换掉JDK 5中发布的CMS收集器。现在这个期望目标已经实现过半了,JDK 9发布之日,G1宣告取代Parallel Scavenge加Parallel Old组合,成为服务端模式下的默认垃圾收集器。

G1收集器运行过程如图:

image-20220630213338954

8. ZGC

ZGC 特征

ZGC 收集器是一款基于 Region 内存布局的,(暂时) 不设分代的,使用了读屏障、染色指针和内存多重映射等技术来实现可并发的标记-整理算法的,以低延迟为首要目标的一款垃圾收集器。

内存布局

ZGC 没有分代的概念

ZGC 的内存布局说起。与 Shenandoah 和 G1一样,ZGC 也采用基于 Region 的堆内存布局,但与它们不同的是 , ZGC 的 Region 具 有 动 态 性 (动态创建和销毁 , 以及动态的区域容量大小)。在 x64硬件平台下 , ZGC 的 Region 可以具有大、中、小三类容量(如下图所示):

  • 小型 Region (Small Region ):容量固定为 2M, 存放小于 256K 的对象。
  • 中型 Region (Medium Region):容量固定为 32M,放置大于等于256K但小于4M的对象。
  • 大型 Region (Large Region): 容量不固定,可以动态变化,但必须为2MB 的整数倍,用于放置 4MB或以上的大对象。
图片

参考链接:https://juejin.cn/post/7095643412082196511

参考链接

垃圾回收算法是如何设计的?

JVM 从入门到放弃之 ZGC 垃圾收集器

Comment and share

Java创建对象的5种方式

创建方式 原理
new关键字 通过调用构造器
反射 (同 new 关键字)
Object.clone 通过复制已有数据,来初始化新建对象实例
反序列化 (同 Object.clone)
Unsafe.allocateInstance 没有初始化实例字段

Java对构造器的约束:

  • 如果一个类没有任何构造器的话,会自动添加一个无参构造器。
  • 子类的构造器需要调用父类的构造器。如果父类存在无参数构造器的话,该调用可以是隐式的,也就是说 Java 编译器会自动添加对父类构造器的调用。但是,如果父类没有无参数构造器,那么子类的构造器则需要显式地调用父类带参数的构造器。
  • 显式调用又可分为两种,一是直接使用“super”关键字调用父类构造器,二是使用“this”关键字调用同一个类中的其他构造器。无论是直接的显式调用,还是间接的显式调用,都需要作为构造器的第一条语句,以便优先初始化继承而来的父类字段。(不过这可以通过调用其他生成参数的方法,或者字节码注入来绕开。)
  • 通过 new 指令新建出来的对象,它的内存其实涵盖了所有父类中的实例字段。也就是说,虽然子类无法访问父类的私有实例字段,或者子类的实例字段隐藏了父类的同名实例字段,但是子类的实例还是会为这些父类实例字段分配内存的。

对象内存占用分布

  1. 每个对象都有一个对象头(Object header)

    由标记字段(Mark Word)和类型指针所构成。其中:mark word用以存储Java虚拟机有关该对象的运行数据:hash码,GC信息以及锁信息,而类型指针则是指向该对象的类。

    1
    2
    3
    4
    5
    |--------------------------------------------------------------|
    | Object Header (128 bits) |
    |------------------------------------|-------------------------|
    | Mark Word (64 bits) | Klass pointer (64 bits) |
    |------------------------------------|-------------------------|
  2. 为了尽量较少对象的内存使用量,64位Java虚拟机引入压缩指针(-XX: +UseCompressedOops,默认开启),将Java对象指针压缩成为32位。

    1
    2
    3
    4
    5
    |--------------------------------------------------------------|
    | Object Header (96 bits) |
    |------------------------------------|-------------------------|
    | Mark Word (64 bits) | Klass pointer (32 bits) |
    |------------------------------------|-------------------------|
  3. 数组对象

    1
    2
    3
    4
    5
    |---------------------------------------------------------------------------------|
    | Object Header (128 bits) |
    |--------------------------------|-----------------------|------------------------|
    | Mark Word(64bits) | Klass pointer(32bits) | array length(32bits) |
    |--------------------------------|-----------------------|------------------------|

压缩指针

将堆中原本 64 位的 Java 对象指针压缩成 32 位的。

原理:

默认情况下,Java 虚拟机堆中对象的起始地址需要对齐至 8 的倍数(内存对齐-XX:ObjectAlignmentInBytes,默认值为 8)。如果一个对象用不到 8N 个字节,那么空白的那部分空间就浪费掉了。这些浪费掉的空间我们称之为对象间的填充(padding)

就算是关闭了压缩指针,Java 虚拟机还是会进行内存对齐。此外,内存对齐不仅存在于对象与对象之间,也存在于对象中的字段之间。比如说,Java 虚拟机要求 long 字段、double 字段,以及非压缩指针状态下的引用字段地址为 8 的倍数。

字段内存对齐的其中一个原因,是让字段只出现在同一 CPU 的缓存行中。如果字段不是对齐的,那么就有可能出现跨缓存行的字段。也就是说,该字段的读取可能需要替换两个缓存行,而该字段的存储也会同时污染两个缓存行。这两种情况对程序的执行效率而言都是不利的

字段重排列

字段重排列,顾名思义,就是 Java 虚拟机重新分配字段的先后顺序,以达到内存对齐的目的。Java 虚拟机中有三种排列方法(对应 Java 虚拟机选项 -XX:FieldsAllocationStyle,默认值为 1)

  1. 如果一个字段占据 C 个字节,那么该字段的偏移量需要对齐至 NC。

    这里偏移量指的是字段地址与对象的起始地址差值。以 long 类为例,它仅有一个 long 类型的实例字段。在使用了压缩指针的 64 位虚拟机中,尽管对象头的大小为 12 个字节,该 long 类型字段的偏移量也只能是 16,而中间空着的 4 个字节便会被浪费掉。

  2. 子类所继承字段的偏移量,需要与父类对应字段的偏移量保持一致。

    在具体实现中,Java 虚拟机还会对齐子类字段的起始位置。对于使用了压缩指针的 64 位虚拟机,子类第一个字段需要对齐至 4N;而对于关闭了压缩指针的 64 位虚拟机,子类第一个字段则需要对齐至 8N。

Java 8 还引入了一个新的注释 @Contended,用来解决对象字段之间的虚共享(false sharing)问题。这个注释也会影响到字段的排列。虚共享是怎么回事呢?

假设两个线程分别访问同一对象中不同的 volatile 字段,逻辑上它们并没有共享内容,因此不需要同步。然而,如果这两个字段恰好在同一个缓存行中,那么对这些字段的写操作会导致缓存行的写回,也就造成了实质上的共享。Java 虚拟机会让不同的 @Contended 字段处于独立的缓存行中,因此你会看到大量的空间被浪费掉。具体的分布算法属于实现细节,注意使用虚拟机选项 -XX:-RestrictContended。如果你在 Java 9 以上版本试验的话,在使用 javac 编译时需要添加 –add-exports java.base/jdk.internal.vm.annotation=ALL-UNNAME

Comment and share

Java作为一种高级编程语言以其较高的可移植性被广泛使用在各个平台。那么它底层运行的原理是什么?

认识JRE和JDK

JRE(Java Runtime Environment):Java运行时环境。Java的所有执行都离不开JRE。JRE包含了Java虚拟机和Java核心类库等。

JDK(Java Development Kit):Java开发工具包。同样包含了JRE,并且还附带了一系列开发,诊断工具。

Java虚拟机具体是如何运行Java字节码的

  1. 执行Java代码首先要将它编译成class文件加载到Java虚拟机中。
  2. 加载后的Java类会被存放于方法区(Method Area)中。
  3. 实际运行时虚拟机会执行方法区中的代码。
  4. Java虚拟机会在内存中划分出堆和栈来存储运行时数据。
    • Java虚拟机会将栈细分为:
      1. 面向Java方法的Java方法栈(也叫Java虚拟机栈)。
      2. 面向本地方法的本地方法栈(用C++写的Native方法)。
      3. 以及存放各个线程执行位置的PC寄存器。
img
  1. 运行过程当中,每当调用进入一个Java方法,Java虚拟机会在当前线程的Java方法栈中生成一个栈帧,用于存放局部变量以及字节码的操作数。这个栈帧大小是提前计算好的,而且Java虚拟机不要求栈帧在内存空间里连续分布。

  2. 退出执行方法时,不管是正常还是异常都会弹出当前线程栈帧,并舍弃。

  3. 硬件执行是需要Java虚拟机将字节码翻译成为机器码:

    1. 解释执行:逐条将字节码翻译成为机器码并执行。优势:无需等待编译。
    2. 即时编译(Just-In-Time Compilation, JIT):即将一个方法中包含的所有字节码编译成为机器码后再执行。优势:实际运行速度更快
    img

HotSpot采用的是混合模式:会对热点代码进行即时编译(JIT)。

Java虚拟机的运行效率

二八定律:即时编译建立在程序符合二八定律的假设上,即百分之二十的代码占据了百分之八十的计算资源。

对于大部分不常用的代码,无需耗费时间将其编译成机器码,而是采取解释执行。

对于近占据小部分的热点代码,可以将其编译成为机器码。

即时编译器:C1,C2和Graal(Java10引入)

C1编译器:又叫Client编译器,面向的是对启动性能有要求的客户端GUI程序,采用的优化手段较少。

C2编译器:又叫Server编译器,面向的是对峰值性能有要求的服务器端程序,采用的优化手段较为复杂。编译时间长,但是生成的代码执行效率高。

Java7开始HotSpot默认采用的是分层编译:热点方法首先会被C1编译,而后热点中的热点会被C2进一步编译。

总结

  1. Java虚拟机Jvm:提供了可移植性:一次书写,处处运行。
  2. 代替我们管理了内存。
  3. 为了提高效率采用了混合编译。

Comment and share

理解RPC

in Architecture

什么是RPC(Remote procedure call)

Remote procedure call is the synchronous language-level transfer of control between programs in address spaces whose primary communication is a narrow channel.—— Bruce Jay Nelson,Remote Procedure Call,Xerox PARC,1981

RPC是一种语言级别的通讯协议,它允许运行于一台计算机上的程序以某种管道作为通讯媒介(即某种网络传输协议),去调用另外一个地址空间(通常为网络上的另外一台计算机)。

所有RPC协议都需要解决哪些问题?

基于 TCP/IP 网络的、支持 C 语言的 RPC 协议,后来也被称为是ONC RPC(Open Network Computing RPC/Sun RPC)。这两个RPC鼻祖。

  1. 如何表示数据
  2. 如何传递数据
  3. 如何标识方法

如何表示数据

将交互双方涉及的数据,转换为某种事先约定好的中立数据流格式来传输,将数据流转换回不同语言中对应的数据类型来使用,即序列化与反序列化

如何传递数据

这里“传递数据”通常指的是应用层协议,实际传输一般是基于标准的 TCP、UDP 等传输层协议来完成的。

如何标识方法

标识调用方法入口,能够找到这个方法。

已经实现的RPC框架

RPC 框架有明显朝着更高层次(不仅仅负责调用远程服务,还管理远程服务)与插件化方向发展的趋势,不再选择自己去解决表示数据、传递数据和表示方法这三个问题,而是将全部或者一部分问题设计为扩展点,实现核心能力的可配置,再辅以外围功能,如负载均衡、服务注册、可观察性等方面的支持。这一类框架的代表,有 Facebook 的 Thrift 和阿里的 Dubbo(现在两者都是 Apache 的)。尤其是断更多年后重启的 Dubbo 表现得更为明显,它默认有自己的传输协议(Dubbo 协议),同时也支持其他协议,它默认采用 Hessian 2 作为序列化器,如果你有 JSON 的需求,可以替换为 Fastjson;如果你对性能有更高的需求,可以替换为Kryo、FST、Protocol Buffers 等;如果你不想依赖其他包,直接使用 JDK 自带的序列化器也可以。这种设计,就在一定程度上缓解了 RPC 框架必须取舍,难以完美的缺憾。

RMI(Sun/Oracle)、Thrift(Facebook/Apache)、Dubbo(阿里巴巴 /Apache)、gRPC(Google)、Motan2(新浪)、Finagle(Twitter)、brpc(百度)、.NET Remoting(微软)、Arvo(Hadoop)、JSON-RPC 2.0(公开规范,JSON-RPC 工作组)

Comment and share

Java执行流转图

img

Java8内存布局

Image

JVM内存与本地内存的区别

  • JVM内存

    • 受JVM内存大小限制,当大小超过参数设置的大小就会报OOM
  • 本地内存

    • 本地内存不受虚拟机内存参数限制,只受物理容量限制
    • 虽然不受参数限制,但是如果内存占用超过了物理内存大小,也会报OOM

Java运行时数据区域

Insert picture description here

  • 程序计数器(PC Registers)

    JVM Program counter 当前线程所执行的字节码的行号指示器,通过改变计数器的值,来选取下一行需要执行的指令。每个线程都有自己的程序计数器。所以程序计数器的结构如下:

    Insert picture description here
    • 程序计数器只占用很小的内存空间,所以可以被忽略。它也是存储最快的空间。
    • JVM规定每个线程都有自己的程序计数器,它的生命周期就是随着线程的执行周期。
    • 线程中任何时候都只有一个叫做当前方法。程序计数器里存储了当前线程正在执行的方法的指令地址。如果是本地方法正在执行则是undefined value。
    • 程序的分支,循环,跳转,异常处理都需要程序计数器控制。
    • 当字节码解释器工作时,它通过改变这个计数器的值来选择下一条要执行的字节码指令。
    • 程序计数器是对物理寄存器的抽象。
    • 它是 Java 虚拟机规范中唯一没有指定任何 outOtMemoryError 条件的区域。 (没有 GC,OOM)
    • 参考原文
  • 虚拟机栈(JVM Stacks)

    虚拟机栈是线程私有的,随线程毁灭,结构示意图:

    Image

每个方法被执行的时候,都会在虚拟机栈中同步创建一个栈帧(stack frame)

每个栈帧的包含: 局部变量表中存储着方法里的java基本数据类型, 局部变量表, 操作数栈, 动态连接, 方法返回地址

  • 线程创建入栈,线程结束出栈。

虚拟机栈可能会抛出两种异常:

- 如果线程请求的栈深度大于虚拟机所规定的栈深度,则会抛出StackOverFlowError即栈溢出
- 如果虚拟机的栈容量可以动态扩展,那么当虚拟机栈申请不到内存时会抛出OutOfMemoryError即OOM内存溢出
  • 本地方法栈

    • 线程私有执行native方法
  • Java堆

    • 对象实例
      • 类初始化生成的对象
      • 基本类型的数组也是对象实例
    • 字符串常量池
      • 字符串常量池原本存放于方法区,jdk7开始放置于堆中
      • 字符串常量池存储的是string对象的直接引用,而不是直接存放的对象,是一张string table
    • 静态变量
      • 静态变量是有static修饰的变量,jdk7时从方法区迁移至堆中
    • 线程分配缓冲区(Thread Local Allocation Buffer)
      • 线程私有,但是不影响java堆的共性
      • 增加线程分配缓冲区是为了提升对象分配时的效率

方法区(Method Area)

方法区是所有线程共享的内存,在java8以前是放在JVM内存中的,由永久代实现,受JVM内存大小参数的限制,在java8中移除了永久代的内容,方法区由元空间(Meta Space)实现,并直接放到了本地内存中,不受JVM参数的限制(当然,如果物理内存被占满了,方法区也会报OOM),并且将原来放在方法区的字符串常量池和静态变量都转移到了Java堆中,方法区与其他区域不同的地方在于,方法区在编译期间和类加载完成后的内容有少许不同,不过总的来说分为这两部分:

  • 类元信息

    • 类元信息在类编译期间放入方法区,里面放置了类的基本信息,包括类的版本、字段、方法、接口以及常量池表(Constant Pool Table)
    • 常量池表(Constant Pool Table)存储了类在编译期间生成的字面量、符号引用(什么是字面量?什么是符号引用?),这些信息在类加载完后会被解析到运行时常量池中
  • 运行时常量池(Runtime Constant Pool)

    • 运行时常量池主要存放在类加载后被解析的字面量与符号引用,但不止这些
    • 运行时常量池具备动态性,可以添加数据,比较多的使用就是String类的intern()方法

直接内存

在jdk1.4中加入了NIO(New Input/Putput)类,引入了一种基于通道(channel)与缓冲区(buffer)的新IO方式,它可以使用native函数直接分配堆外内存,然后通过存储在java堆中的DirectByteBuffer对象作为这块内存的引用进行操作,这样可以在一些场景下大大提高IO性能,避免了在java堆和native堆来回复制数据。

FAQ

类常量池、运行时常量池、字符串常量池有什么关系?有什么区别?

类常量池与运行时常量池都存储在方法区,而字符串常量池在jdk7时就已经从方法区迁移到了java堆中。

在类编译过程中,会把类元信息放到方法区,类元信息的其中一部分便是类常量池,主要存放字面量和符号引用,而字面量的一部分便是文本字符,在类加载时将字面量和符号引用解析为直接引用存储在运行时常量池;对于文本字符来说,它们会在解析时查找字符串常量池,查出这个文本字符对应的字符串对象的直接引用,将直接引用存储在运行时常量池;字符串常量池存储的是字符串对象的引用,而不是字符串本身。

Comment and share

类加载器从应用程序和Java API加载类文件。只有运行中的程序实际需要Java API中的类的时候才会被加载到虚拟机。

img

加载

  • 加载是指查找字节流,并且据此创建类的过程。除了数组类没有对应的字节流是由JVM直接生成的。其他类,JVM则需要借助类加载器来完成查找字节流的过程。
img
  • 在JVM中,类的唯一性是由类加载器实例以及类的全名一同决定的。即便是同一串字节流,经由不同的类加载器加载,也会得到不同的类。在大型应用中我们往往借助这一特性,来运行同一个类的不同版本。

    启动类加载器(Bootstrap class loader)

  1. 由C++实现,没有对应的Java对象,因此在Java中只能用null来指代。
  2. 除了Bootstrap类加载器其他类加载器都是ClassLoader的子类,因此有对应的Java对象。这些类加载器都必须先由另外一个类加载器,比如启动类加载器加载至JVM当中,方能执行类加载。
  3. Java9之前启动类加载器只负责加载最为基础,最为重要的类:JRE的lib目录下的jar(以及虚拟机参数 -Xbootclasspath指定的类)。

扩展类加载器(Extension class loader)

  1. 扩展类加载器的父类加载器是启动类加载器。
  2. 负责加载相对次要、但又通用的类,比如存放在JRE的lib/ext目录下的jar以及由系统变量java.ext.dirs指定的类。

应用类加载器(Application class loader)

  1. 应用类加载器的父类加载器是扩展类加载器。
  2. 负责加载应用程序路径下的类。虚拟机参数 -cp/-classpath,系统变量 java.class.path或者环境变量CLASSPATH所指定的类。

Java 9引入了模块系统,并且略微更改了上述类加载器。扩展类加载器被更改为**平台类加载器(platform class loader)**。Java SE中除了少数几个关键模块,比如java.base是由启动类加载器加载之外,其他模块均由平台类加载器所加载。

自定义类加载器

除了JVM提供的类加载器外,可以自定义类加载器,通过继承ClassLoader类实现,主要重写findClass方法。实现特殊的加载方式。举例:对class文件进行加密,加载时再利用自定义类加载器对其进行解密。

双亲委派模型

在JVM中,如果一个类加载器收到类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求委托给自己的父类加载器完成。每个类加载器都是如此,只有当自己的父类加载器在自己负责加载的范围内找不到指定类(ClassNotFoundException)时,子类才会进行自己去尝试加载。

作用:

  1. 提高安全性:防止覆盖系统类库中的类,提高安全性。
  2. 防止程序混乱:重复加载。
图片描述

链接

验证

确保被加载的类符合JVM规范。

准备

为被加载类中的静态字段分配内存。

解析

在 class 文件被加载至 Java 虚拟机之前,这个类无法知道其他类及其方法、字段所对应的具体地址,甚至不知道自己方法、字段的地址。因此,每当需要引用这些成员时,Java 编译器会生成一个符号引用。在运行阶段,这个符号引用一般都能够无歧义地定位到具体目标上。

解析阶段的目的是将符号引用解析成为实际引用。如果符号引用指向一个未被加载的类,或者未被加载的字段或者方法。那么解析将触发这个类的加载(但未必触发这个类的链接以及初始化)。

初始化

类加载的最后一步是初始化,便是为标记为常量值的字段赋值,以及执行 < clinit > 方法的过程。Java 虚拟机会通过加锁来确保类的 < clinit > 方法仅被执行一次。

  1. 当虚拟机启动时,初始化用户指定的主类;
  2. 当遇到用以新建目标类实例的 new 指令时,初始化 new 指令的目标类;
  3. 当遇到调用静态方法的指令时,初始化该静态方法所在的类;
  4. 当遇到访问静态字段的指令时,初始化该静态字段所在的类;子类的初始化会触发父类的初始化;
  5. 如果一个接口定义了 default 方法,那么直接实现或者间接实现该接口的类的初始化,会触发该接口的初始化;
  6. 使用反射 API 对某个类进行反射调用时,初始化这个类;
  7. 当初次调用 MethodHandle 实例时,初始化该 MethodHandle 指向的方法所在的类。

Comment and share

关于写博客

in 杂思

技术人的工作思考

刚开始作为一个coder,可能会好奇各种各样的新技术,进而去探索,然后搁置(因为工作上可能并不会用到)。最后这些知识可能就会藏在脑海深处。

作为一个backend开发,我几乎学习了java周边所有的技术栈。从Java,MySQL,Redis,Spring全家桶,Kafka,K8s,Netty,,,但是说我算精通吗?我的答案是否定的。

  1. 从全局看我缺乏上层架构图,将这些知识串联起来。
  2. 从细节看我没有对技术细节的整理输出。从而会导致捡了西瓜丢了芝麻的尴尬场面。

让博客成为你的学习输出

我层使用过各种笔记软件和博客网站:“有道云笔记”,“Enovy note”, “CSDN blog”,“博客园”,“InfoQ”等等。用了一阵后,我发现这些软件都不能让我保持专注于自己的写作,并且没有版本跟踪。而且在“种类”和“标签”检索上也没有hexo+github page这种更清爽。如图

image-20220503222913905

如何写好博客

  1. 使用markdown,这是技术人的必备第二语言。

  2. 为你的博客起一个好名字

    为博客起一个好名字,更能让你再接下来复盘的时候检索到你这篇博客的目的。所以你的博客名字最好有总结性。

  3. 博客段落设置

    • 解释这篇博客包含哪些内容
    • 切入主题,分1,2,3带上配图
    • 总结
  4. 技术是不断迭代的,记得一定要维护你的博客跟上技术更新的步骤。

持之以恒

好记性不如烂笔头,只有自己的记录才最靠谱。

Comment and share

  • page 1 of 1
Author's picture

Topsion

Fullstack Developer


Coder


Xi'an China