Java 类加载编程面试题

首先看一段代码

它的输出结果是多少?

还是上面这段代码,把private static SingleTon singleTon = new SingleTon();移到类里的第一行。

打印结果又是什么呢?

 

Java 类加载编程面试题

类加载

虚拟机把描述类的数据从class文件加载到内存,并对数据进行校验,转换解析和初始化,最终形成可以被虚拟机直接使用的java类型,这就是jvm的类加载机制。

运行时的动态加载给java语言提供了高度的灵活性,动态拓展也成为了java语言的特性之一。

类的生命周期

类的初始化时机

1、jvm 遇到 new 、 putStatic、 getStatic 、invokeStatic 指令时

2、使用反射java.lang.reflect时

3、初始化子类时,若父类还没初始化,那也要初始化父类

4、启动jvm虚拟机时,需要初始化指定的主类

5、java 7 动态语言的 MethodHandler 在解析时,也要对对应的类进行初始化

以上五种类型称为对一个类进行主动引用,除此之外,所有引用类型的方式都不会触发初始化,称为被动引用。

1、通过子类引用父类的静态变量,不会导致子类初始化

2、通过数组定义来引用类,不会触发此类的初始化

0

Java的四种引用类型

前言

上一篇文章我们讨论了ThreadLocal相关的问题,其中内存泄漏的原因是因为Thread对象内部维护的ThreadLocalMap,这个Map的Key是弱引用类型(WeakReference),而Value是强引用类型,如果Key被回收,Value却不会被回收。本期让我们详细分析一下Java的四种引用。

Java的引用

从 JDK1.2 开始,对象的引用从原来的1种级别增加到了4种级别,从而使程序能更加灵活地控制对象的生命周期。这4种级别由高到低依次为:强引用、软引用、弱引用和虚引用
Java.lang.ref 是 Java 类库中比较特殊的一个包,它提供了与 Java垃圾回收器密切相关的引用类。StrongReference(强引用),SoftReference(软引用),WeakReference(弱引用),PhantomReference(虚引用)。这四种引用的强度按照上面的顺序依次减弱。

引用类图

Java的四种引用类型

强引用(StrongReference)为JVM内部实现。其他三类引用类型全部继承自Reference父类。

强引用

最常用到的引用类型,StrongReference这个类并不存在,而是在JVM底层实现。默认的对象都是强引用类型,继承自Reference、SoftReference、WeakReference、PhantomReference的引用类型非强引用。
示例:

强引用是使用最普遍的引用。如果一个对象具有强引用,那垃圾回收器绝不会回收它。当内存空间不足,Java 虚拟机宁愿抛出OutOfMemoryError错误,使程序异常终止,也不会靠随意回收具有强引用的对象来解决内存不足的问题。

软引用

软引用是一种比强引用生命周期稍弱的一种引用类型。在JVM内存充足的情况下,软引用并不会被垃圾回收器回收,只有在JVM内存不足的情况下,才会被垃圾回收器回收。 所以软引用的这种特性,一般用来实现一些内存敏感的缓存,只要内存空间足够,对象就会保持不被回收掉,比如网页缓存、图片缓存等。
示例:

弱引用

弱引用是一种比软引用生命周期更短的引用。他的生命周期很短,不论当前内存是否充足,都只能存活到下一次垃圾收集之前。 不过,由于垃圾回收器是一个优先级很低的线程,因此不一定会很快发现那些只具有弱引用的对象。弱引用可以和一个引用队列(ReferenceQueue)联合使用, 如果弱引用所引用的对象被垃圾回收, Java 虚拟机就会把这个弱引用加入到与之关联的引用队列中。
示例:

执行结果大概率为:

示例中有一个细节,如果不加Thread.sleep(20000);这段代码,WeakReference大概率不会被回收,因为System.gc();仅仅是提醒JVM该执行垃圾回收了。 但JVM具体什么时候执行,并不由我们的程序控制,所以在发起提醒后,要sleep一段时间等待JVM发生垃圾回收。

虚引用

“虚引用”顾名思义,就是形同虚设,与其他几种引用都不同,虚引用并不会决定对象的生命周期。如果一个对象仅持有虚引用,那么它就和没有任何引用一样,在任何时候都可能被垃圾回收器回收。
虚引用主要用来跟踪对象被垃圾回收器回收的活动。虚引用与软引用和弱引用的一个区别在于:虚引用必须和引用队列 (ReferenceQueue) 联合使用。 当垃圾回收器准备回收一个对象时,如果发现它还有虚引用,就会在回收对象的内存之前,把这个虚引用加入到与之关联的引用队列中。

上面程序执行的结果一直是null,和没有引用几乎一致。

引用类型对比

引用类型 取得目标对象方式 垃圾回收条件 是否可能内存泄漏
强引用 直接调用 不回收 可能
软引用 通过 get()方法 视内存情况回收 不可能
弱引用 通过 get()方法 发生gc时回收 不可能
虚引用 无法取得 随时可能被回收 不可能

各种引用的使用场景

我们希望能描述这样一类对象:当内存空间还足够时,则能保留在内存之中;如果内存空间在进行垃圾收集后还是非常紧张,则可以抛弃这些对象。很多系统的缓存功能都符合这样的应用场景。

最容易想到的就是缓存了,内存不足时释放部分数据,类似Redis/Ehcache之类的淘汰策略。

下面列出一下JDK/框架中的应用场景:

  • java.util.WeakHashMap – jdk
  • java.util.concurrent.ArrayBlockingQueue – jdk
  • org.springframework.util.ConcurrentReferenceHashMap – spring中大量使用了此缓存,包括spring-BeanUtils
0

Redis面试题:为什么Redis很快?

面试官经常会问到单线程的Redis为什么这么快?
为了阐明这个问题, 可以分三部分讲解:
(1) 第一部分: Redis到底有多快
(2) 第二部分: 详细讲解Redis高性能原因
(3) 第三部分: 影响Redis性能的因素

Redis到底有多快

  1. 可以使用redis-benchmark对Redis的性能进行评估,命令行提供了普通/流水线方式、不同压力评估特定命令的性能的功能。
  2. redis性能卓越,作为key-value系统最大负载数量级为10W/s, set和get耗时数量级为10ms和5ms。使用流水线的方式可以提升redis操作的性能。

redis-benchmark实用程序可模拟N个客户端同时发送M个总查询的运行命令(类似于Apache的ab实用程序)。

支持以下选项:

测试数据示例

上例截取了SET/GET/INCR的测试结果。

测试结果包括测试的环境参数(请求量、client数量、有效载荷)以及请求耗时的TP值。

redis-benchmark默认使用10万请求量, 50个clinet,有效载荷为3字节进行测试。

返回结果可以看出SET/GET/INCR命令在10万的请求量下,总的请求耗时均低于0.1s以内。 以QPS=10W为例, 计算出来的平均耗时为2ms左右(1/(10W/50))。

Redis测试经验数据

硬件环境和软件配置

Redis系统负载

  1. 不使用流水线测试结果

  1. 使用流水线测试结果

从以上可以看出Redis作为key-value系统读写负载大致在10W+QPS, 使用流水线技术能够显著提升读写性能。

耗时情况

  1. 不使用流水线测试结果

所有set操作均在10ms内完成, get操作均在5ms以下。

Redis为什么那么快

Redis是一个单线程应用,所说的单线程指的是Redis使用单个线程处理客户端的请求。
虽然Redis是单线程的应用,但是即便不通过部署多个Redis实例和集群的方式提升系统吞吐, 从官网给出的数据可以看出,Redis处理速度非常快。

Redis性能非常高的原因主要有以下几点:

  • 内存存储:Redis是使用内存(in-memeroy)存储,没有磁盘IO上的开销
  • 单线程实现:Redis使用单个线程处理请求,避免了多个线程之间线程切换和锁资源争用的开销
  • 非阻塞IO:Redis使用多路复用IO技术,在poll,epool,kqueue选择最优IO实现
  • 优化的数据结构:Redis有诸多可以直接应用的优化数据结构的实现,应用层可以直接使用原生的数据结构提升性能

下面详细介绍非阻塞IO和优化的数据结构

多路复用IO

在《unix网络编程 卷I》中详细讲解了unix服务器中的5种IO模型。

一个IO操作一般分为两个步骤:

  1. 等待数据从网络到达, 数据到达后加载到内核空间缓冲区
  2. 数据从内核空间缓冲区复制到用户空间缓冲区

按照两个步骤是否阻塞线程,分为阻塞/非阻塞, 同步/异步。

五种IO模型分类:

阻塞 非阻塞
同步 阻塞IO 非阻塞IO,IO多路复用,信号驱动IO
异步IO 异步IO

阻塞IO

在linux中,默认情况下所有的socket都是blocking,一个典型的读操作流程大概是这样:


非阻塞IO

Linux下,可以通过设置socket使其变为non-blocking。当对一个non-blocking socket执行读操作时,流程是这个样子:

IO多路复用

IO multiplexing这个词可能有点陌生,但是如果我说select/epoll,大概就都能明白了。有些地方也称这种IO方式为事件驱动IO(event driven IO)。我们都知道,select/epoll的好处就在于单个process就可以同时处理多个网络连接的IO。它的基本原理就是select/epoll这个function会不断的轮询所负责的所有socket,当某个socket有数据到达了,就通知用户进程。它的流程如图:

信号驱动IO

异步IO

Linux下的asynchronous IO其实用得不多,从内核2.6版本才开始引入。先看一下它的流程:

介绍完unix或者类unix系统IO模型之后, 我们看下redis怎么处理客户端连接的?

Reids的IO处理

总的来说Redis使用一种封装多种(select,epoll, kqueue等)实现的Reactor设计模式多路复用IO处理客户端的请求。

Reactor设计模式

 

Reactor设计模式常常用来实现事件驱动。除此之外, Redis还封装了不同平台多路复用IO的不同的库。处理过程如下:

IO库封装

因为 Redis 需要在多个平台上运行,同时为了最大化执行的效率与性能,所以会根据编译平台的不同选择不同的 I/O 多路复用函数作为子模块。

具体选择过程如下:
io多路复用的函数选择

Redis 会优先选择时间复杂度为 O(1) 的 I/O 多路复用函数作为底层实现,包括 Solaries 10 中的 evport、Linux 中的 epoll 和 macOS/FreeBSD 中的 kqueue,上述的这些函数都使用了内核内部的结构,并且能够服务几十万的文件描述符。

但是如果当前编译环境没有上述函数,就会选择 select 作为备选方案,由于其在使用时会扫描全部监听的描述符,所以其时间复杂度较差 O(n),并且只能同时服务 1024 个文件描述符,所以一般并不会以 select 作为第一方案使用。

丰富高效的数据结构

Redis提供了丰富的数据结构,并且不同场景下提供不同实现。

Redis作为key-value系统,不同类型的key对应不同的操作或者操作对应不同的实现,相同的key也会有不同的实现。Redis对key进行操作时,会进行类型检查,调用不同的实现。

为了解决以上问题, Redis 构建了自己的类型系统, 这个系统的主要功能包括:

redisObject 对象。
基于 redisObject 对象的类型检查。
基于 redisObject 对象的显式多态函数。
对 redisObject 进行分配、共享和销毁的机制。

redisObject定义:

type 、 encoding 和 ptr 是最重要的三个属性。

Redis支持4种type, 8种编码, 分别为:

redis的type

有了redisObject之后, 对于特定key的操作过程就可以很容易的实现:

Redis命令的调用过程

Redis除了提供丰富的高效的数据结构外, 还提供了如HyperLogLog, Geo索引这样高效的算法。

影响 Redis 性能的 5 大因素

  1. Redis 内部的阻塞式操作;
  2. CPU 核和 NUMA 架构的影响;
  3. Redis 关键系统配置;
  4. Redis 内存碎片;
  5. Redis 缓冲区。

一、Redis 内部的阻塞式操作

1.1 有哪些阻塞点?
看看要与哪些对象交互以及有什么操作:

逐个分析

1.1.1 和客户端交互时的阻塞点
Redis 使用了 IO 多路复用机制,网络IO将不是阻塞点(只考虑Redis本身而非网络环境)。

键值对的增删改查操作是 Redis 和客户端交互的主要部分,复杂度高的增删改查操作肯定会阻塞 Redis。O(N)级别的操作肯定会阻塞了。

Redis 中涉及集合的操作复杂度通常为 O(N),例如集合元素全量查询操作 HGETALL、SMEMBERS,以及集合的聚合统计操作,例如求交、并和差集。

第一个阻塞点:集合全量查询和聚合操作。

其实,删除操作的本质是要释放键值对占用的内存空间。释放内存只是第一步,为了更加高效地管理内存空间,在应用程序释放内存时,操作系统需要把释放掉的内存块插入一个空闲内存块的链表,以便后续进行管理和再分配。这个过程本身需要一定时间,而且会阻塞当前释放内存的应用程序,所以,如果一下子释放了大量内存,空闲内存块链表操作时间就会增加,相应地就会造成 Redis 主线程的阻塞。

删除大量键值对数据的时候,最典型的就是删除包含了大量元素的集合,也称为 bigkey 删除。

不同元素数量的集合在进行删除操作时所消耗的时间:

很显然,Redis 的第二个阻塞点是 bigkey 删除操作

频繁删除键值对都是潜在的阻塞点了,那么 FLUSHDB 和 FLUSHALL 操作必然也是一个潜在的阻塞风险,这就是 Redis 的第三个阻塞点:清空数据库。

1.1.2 和磁盘的交互的阻塞点
磁盘 IO 一般都是比较费时费力的,需要重点关注。

其实Redis 进一步设计为采用子进程的方式生成 RDB 快照文件,以及执行 AOF 日志重写操作,这样慢速的磁盘 IO 就不会阻塞主线程了。

但Redis 直接记录 AOF 日志时,同步写磁盘的操作的耗时大约是 1~2ms,如果有大量的写操作需要记录在 AOF 日志中,并同步写回的话,就会阻塞主线程了。

Redis 的第四个阻塞点了:AOF 日志同步写。

1.1.3 主从节点交互时的阻塞点
主从同步时,主库复制创建+传输RDB文件都是子进程完成并不阻塞,但从库接收RDB更新时必定flushdb清库,形成上面讲的第三个阻塞点。

另外从库加载RDB到内存和RDB大小有关,越大越慢,Redis 的第五个阻塞点:加载 RDB 文件

1.1.4 切片集群交互的阻塞点
每个 Redis 实例上分配的哈希槽信息需要在不同实例间进行传递,同时,当需要进行负载均衡或者有实例增删时,数据会在不同的实例间进行迁移。不过,哈希槽的信息量不大,而数据迁移是渐进式执行的,所以,一般来说,这两类操作对 Redis 主线程的阻塞风险不大。

如果你使用了 Redis Cluster 方案,而且同时正好迁移的是 bigkey 的话,就会造成主线程的阻塞,因为 Redis Cluster 使用了同步迁移。

1.2 解决办法、异步执行一下?
总结下五个阻塞点:

  1. 集合全量查询和聚合操作;
  2. bigkey 删除;
  3. 清空数据库;
  4. AOF 日志同步写;
  5. 从库加载 RDB 文件。

Redis 提供了异步线程机制,这五大阻塞式操作都可以被异步执行吗?

1.2.1 异步执行对操作的要求
如果一个操作能被异步执行,就意味着它并不是 Redis 主线程的关键路径上的操作。

关键路径上的操作:客户端把请求发送给 Redis 后,就干等着 Redis 返回数据结果的操作。

1.2.2 集合查询聚合操作
读操作是典型的关键路径操作,客户端发送了读操作之后,就会等待读取的数据返回,以便进行后续的数据处理。

而 Redis 的第一个阻塞点“集合全量查询和聚合操作”都涉及到了读操作,所以,它们是不能进行异步操作了。

1.2.3 删除操作
删除操作并不需要给客户端返回具体的数据结果,所以不算是关键路径操作。第二个阻塞点“bigkey 删除”,和第三个阻塞点“清空数据库”,都是对数据做删除,并不在关键路径上。

1.2.4 AOF同步写操作
为了保证数据可靠性,Redis 实例需要保证 AOF 日志中的操作记录已经落盘,这个操作虽然需要实例等待,但它并不会返回具体的数据结果给实例。所以,我们也可以启动一个子线程来执行 AOF 日志的同步写,而不用让主线程等待 AOF 日志的写完成。

1.2.5 加载RDB文件
从库要想对客户端提供数据存取服务,就必须把 RDB 文件加载完成。所以,这个操作也属于关键路径上的操作,我们必须让从库的主线程来执行

综上,所以,我们可以使用 Redis 的异步子线程机制来实现 bigkey 删除,清空数据库,以及 AOF 日志同步写。

1.3 异步的子线程机制
Redis 主线程启动后,会使用操作系统提供的 pthread_create 函数创建 3 个子线程,分别由它们负责 AOF 日志写操作、键值对删除以及文件关闭的异步执行。

主线程通过一个链表形式的任务队列和子线程进行交互。当收到键值对删除和清空数据库的操作时,主线程会把这个操作封装成一个任务,放入到任务队列中,然后给客户端返回一个完成信息,表明删除已经完成。

但实际上,这个时候删除还没有执行,等到后台子线程从任务队列中读取任务后,才开始实际删除键值对(Redis 4.0+),并释放相应的内存空间。因此,我们把这种异步删除也称为惰性删除(lazy free)。此时,删除或清空操作不会阻塞主线程,这就避免了对主线程的性能影响。

和惰性删除类似,当 AOF 日志配置成 everysec 选项后,主线程会把 AOF 写日志操作封装成一个任务,也放到任务队列中。后台子线程读取任务后,开始自行写入 AOF 日志,这样主线程就不用一直等待 AOF 日志写完了。

二、CPU 核和 NUMA 架构的影响

要了解 CPU 对 Redis 具体有什么影响,我们得先了解一下 CPU 架构。

2.1 主流的 CPU 架构
2.1.1 简介
一个 CPU 处理器中一般有多个运行核心,我们把一个运行核心称为一个物理核。每个物理核都可以运行应用程序。

2.1.2 缓存
每个物理核都拥有私有的一级缓存(Level 1 cache,简称 L1 cache),包括一级指令缓存和一级数据缓存,以及私有的二级缓存(Level 2 cache,简称 L2 cache)。

当数据或指令保存在 L1、L2 缓存时,物理核访问它们的延迟不超过 10 纳秒,速度非常快。

但其他的物理核无法对这个核的缓存空间进行数据存取,且只有KB级大小。

所以,不同的物理核还会共享一个共同的三级缓存(Level 3 cache,简称为 L3 cache)。L3 缓存能够使用的存储资源比较多,所以一般比较大,能达到几 MB 到几十 MB,这就能让应用程序缓存更多的数据。当 L1、L2 缓存中没有数据缓存时,可以访问 L3,尽可能避免访问内存。

2.1.3 线程
现在主流的 CPU 处理器中,每个物理核通常都会运行两个超线程,也叫作逻辑核。同一个物理核的逻辑核会共享使用 L1、L2 缓存。总结如图:

2.1.4 多CPU架构
主流的服务器上,一个 CPU 处理器(也称 CPU Socket)会有 10 到 20 多个等几十个物理核。不同处理器间通过总线连接。CPU Socket 的架构:

在多 CPU 架构上,应用程序可以在不同的处理器上运行。

如果应用程序先在一个 Socket 上运行,并且把数据保存到了内存,然后被调度到另一个 Socket 上运行,此时,应用程序再进行内存访问时,就需要访问之前 Socket 上连接的内存,这种访问属于远端内存访问。和访问 Socket 直接连接的内存相比,远端内存访问会增加应用程序的延迟。

多 CPU 架构下,一个应用程序访问所在 Socket 的本地内存和访问远端内存的延迟并不一致,所以,我们也把这个架构称为非统一内存访问架构(Non-Uniform Memory Access,NUMA 架构)

2.2 CPU多核到底是怎么影响Redis的
在多核 CPU 的场景下,一旦应用程序需要在一个新的 CPU 核上运行,那么,运行时信息就需要重新加载到新的 CPU 核上。而且,新的 CPU 核的 L1、L2 缓存也需要重新加载数据和指令,这会导致程序的运行时间增加。

而且,Redis 实例需要等待这个重新加载的过程完成后,才能开始处理请求,所以,这也会导致一些请求的处理时间增加。

2.2.1 案例
当时,项目需求是要对 Redis 的 99% 尾延迟进行优化,要求 GET 尾延迟小于 300 微秒,PUT 尾延迟小于 500 微秒。

99% 的请求延迟小于的值就是 99% 尾延迟。比如说,我们有 1000 个请求,假设按请求延迟从小到大排序后,第 991 个请求的延迟实测值是 1ms,而前 990 个请求的延迟都小于 1ms,所以,这里的 99% 尾延迟就是 1ms。

避免了许多延迟增加的情况,后来,仔细检测了 Redis 实例运行时的服务器 CPU 的状态指标值,这才发现,CPU 的 context switch (上下文切换)次数比较多。

可以使用 taskset 命令把一个程序绑定在一个核上运行。比如说,我们执行下面的命令,就把 Redis 实例绑在了 0 号核上,其中,“-c”选项用于设置要绑定的核编号。

taskset -c 0 ./redis-server

Redis 实例的 GET 和 PUT 的 99% 尾延迟一下子就分别降到了 260 微秒和 482 微秒

2.3 CPU 的 NUMA 架构对 Redis 性能的影响
网络中断程序是要和 Redis 实例进行网络数据交互的,网络中断处理程序从网卡硬件中读取数据,并把数据写入到操作系统内核维护的一块内存缓冲区。

内核会通过 epoll 机制触发事件,通知 Redis 实例,Redis 实例再把数据从内核的内存缓冲区拷贝到自己的内存空间,如下图所示:

如果网络中断处理程序和 Redis 实例各自所绑的 CPU 核不在同一个 CPU Socket 上,那么,Redis 实例读取网络数据时,就需要跨 CPU Socket 访问内存,这个过程会花费较多时间

网上测试显示,和访问 CPU Socket 本地内存相比,跨 CPU Socket 的内存访问延迟增加了 18%

为了避免 Redis 跨 CPU Socket 访问网络数据,我们最好把网络中断程序和 Redis 实例绑在同一个 CPU Socket 上

三、Redis 关键系统配置

3.1 文件系统:AOF 模式
为了保证数据可靠性,Redis 会采用 AOF 日志或 RDB 快照。其中,AOF 日志提供了三种日志写回策略:no、everysec、always。这三种写回策略依赖文件系统的两个系统调用完成,也就是 write 和 fsync,

write 只要把日志记录写到内核缓冲区,就可以返回了,并不需要等待日志实际写回到磁盘;而 fsync 需要把日志记录写回到磁盘后才能返回,时间较长。

AOF 重写会对磁盘进行大量 IO 操作,同时,fsync 又需要等到数据写到磁盘后才能返回,所以,当 AOF 重写的压力比较大时,就会导致 fsync 被阻塞。虽然 fsync 是由后台子线程负责执行的,但是,主线程会监控 fsync 的执行进度。

当主线程使用后台子线程执行了一次 fsync,需要再次把新接收的操作记录写回磁盘时,如果主线程发现上一次的 fsync 还没有执行完,那么它就会阻塞。

可以检查下 Redis 配置文件中的 appendfsync 配置项,该配置项的取值表明了 Redis 实例使用的是哪种 AOF 日志写回策略,如下:

如果 AOF 写回策略使用了 everysec 或 always 配置,请先确认下业务方对数据可靠性的要求,明确是否需要每一秒或每一个操作都记日志。

在有些场景中(例如 Redis 用于缓存),数据丢了还可以从后端数据库中获取,并不需要很高的数据可靠性。

3.2 操作系统:swap
Redis 是内存数据库,内存使用量大,如果没有控制好内存的使用量,或者和其他内存需求大的应用一起运行了,就可能受到 swap 的影响,而导致性能变慢。

触发 swap 的原因主要是物理机器内存不足,对于 Redis 而言,有两种常见的情况:

  • Redis 实例自身使用了大量的内存,导致物理机器的可用内存不足;
  • 和 Redis 实例在同一台机器上运行的其他进程,在进行大量的文件读写操作。文件读写本身会占用系统内存,这会导致分配给 Redis 实例的内存量变少,进而触发 Redis 发生 swap。

操作系统本身会在后台记录每个进程的 swap 使用情况,即有多少数据量发生了 swap。你可以先通过下面的命令查看 Redis 的进程号,这里是 5332。

进入 Redis 所在机器的 /proc 目录下的该进程目录中:

3.3 操作系统:内存大页
Linux 内核从 2.6.38 开始支持内存大页机制,该机制支持 2MB 大小的内存页分配,而常规的内存页分配是按 4KB 的粒度来执行的。

为了提供数据可靠性保证,需要将数据做持久化保存。

客户端的写请求可能会修改正在进行持久化的数据。在这一过程中,Redis 就会采用写时复制机制,也就是说,一旦有数据要被修改,Redis 并不会直接修改内存中的数据,而是将这些数据拷贝一份,然后再进行修改。

如果采用了内存大页,那么,即使客户端请求只修改 100B 的数据,Redis 也需要拷贝 2MB 的大页。相反,如果是常规内存页机制,只用拷贝 4KB。

关闭内存大页

四、Redis 内存碎片

经常会遇到这样一个问题:明明做了数据删除,数据量已经不大了,为什么使用 top 命令查看时,还会发现 Redis 占用了很多内存呢?

这是因为,当数据删除后,Redis 释放的内存空间会由内存分配器管理,并不会立即返回给操作系统。所以,操作系统仍然会记录着给 Redis 分配了大量内存。

Redis 释放的内存空间可能并不是连续的,那么,这些不连续的内存空间很有可能处于一种闲置的状态。

4.1 是什么
类似于,一个饭店有十张桌子,之前有十个人客人,每人占了一桌(空出很多单独的小座位),此时来了一对夫妇,那么他们就没得一起坐的位置了。很多单独的小座位就是内存碎片。

4.2 是如何形成的?
4.2.1 内因:内存分配器的分配策略
内存分配器的分配策略就决定了操作系统无法做到“按需分配”。这是因为,内存分配器一般是按固定大小来分配内存,而不是完全按照应用程序申请的内存空间大小给程序分配。

Redis 可以使用 libc、jemalloc、tcmalloc 多种内存分配器来分配内存,默认使用 jemalloc。

jemalloc 的分配策略之一,是按照一系列固定的大小划分内存空间,例如 8 字节、16 字节、32 字节、48 字节,…, 2KB、4KB、8KB 等。

这样的分配方式本身是为了减少分配次数。例如,Redis 申请一个 20 字节的空间保存数据,jemalloc 就会分配 32 字节,此时,如果应用还要写入 10 字节的数据,Redis 就不用再向操作系统申请空间了

如果 Redis 每次向分配器申请的内存空间大小不一样,这种分配方式就会有形成碎片的风险,而这正好来源于 Redis 的外因

4.2.2 外因是 Redis 的负载特征
应用 A 保存 6 字节数据,jemalloc 按分配策略分配 8 字节。如果应用 A 不再保存新数据,那么,这里多出来的 2 字节空间就是内存碎片了,如下图所示:

第二个外因是,这些键值对会被修改和删除,这会导致空间的扩容和释放。

如果应用 E 想要一个 3 字节的连续空间,显然是不能得到满足的。因为,虽然空间总量够,但却是碎片空间,并不是连续的。

4.3 判断是否有内存碎片?
Redis 自身提供了 INFO 命令,可以用来查询内存使用的详细信息

这里有一个 mem_fragmentation_ratio 的指标,它表示的就是 Redis 当前的内存碎片率。

知道了这个指标,我们该如何使用呢?提供一些经验阈值:

mem_fragmentation_ratio 大于 1 但小于 1.5。这种情况是合理的。这是因为,刚才我介绍的那些因素是难以避免的。毕竟,内因的内存分配器是一定要使用的,分配策略都是通用的,不会轻易修改;而外因由 Redis 负载决定,也无法限制。所以,存在内存碎片也是正常的。

mem_fragmentation_ratio 大于 1.5 。这表明内存碎片率已经超过了 50%。一般情况下,这个时候,我们就需要采取一些措施来降低内存碎片率了。

4.4 如何清理
当 Redis 发生内存碎片后,一个“简单粗暴”的方法就是重启 Redis 实例。这不是一个“优雅”的方法,毕竟,重启 Redis 会带来两个后果:

如果 Redis 中的数据没有持久化,那么,数据就会丢失;

即使 Redis 数据持久化了,我们还需要通过 AOF 或 RDB 进行恢复,恢复时长取决于 AOF 或 RDB 的大小,如果只有一个 Redis 实例,恢复阶段无法提供服务。

幸运的是,从 4.0-RC3 版本以后,Redis 自身提供了一种内存碎片自动清理的方法,我们先来看这个方法的基本机制。

需要注意的是:碎片清理是有代价的,操作系统需要把多份数据拷贝到新位置,把原有空间释放出来,这会带来时间开销。因为 Redis 是单线程,在数据拷贝时,Redis 只能等着,这就导致 Redis 无法及时处理请求,性能就会降低。

Redis 需要启用自动内存碎片清理,可以把 activedefrag 配置项设置为 yes,命令如下:

这个命令只是启用了自动清理功能,但是,具体什么时候清理,会受到下面这两个参数的控制。

  • active-defrag-ignore-bytes 100mb:表示内存碎片的字节数达到 100MB 时,开始清理;
  • active-defrag-threshold-lower 10:表示内存碎片空间占操作系统分配给 Redis 的总空间比例达到 10% 时,开始清理。

五、Redis 缓冲区

5.1 是什么
缓冲区的功能其实很简单,主要就是用一块内存空间来暂时存放命令数据,以免出现因为数据和命令的处理速度慢于发送速度而导致的数据丢失和性能问题。

5.2 风险
缓冲区的内存空间有限,如果往里面写入数据的速度持续地大于从里面读取数据的速度,就会导致缓冲区需要越来越多的内存来暂存数据。当缓冲区占用的内存超出了设定的上限阈值时,就会出现缓冲区溢出。如果发生了溢出,就会丢数据了。那是不是不给缓冲区的大小设置上限,就可以了呢?显然不是,随着累积的数据越来越多,缓冲区占用内存空间越来越大,一旦耗尽了 Redis 实例所在机器的可用内存,就会导致 Redis 实例崩溃。

5.3 总结
从缓冲区溢出对 Redis 的影响的角度,我再把这四个缓冲区分成两类做个总结。

  • 缓冲区溢出导致网络连接关闭:普通客户端、订阅客户端,以及从节点客户端,它们使用的缓冲区,本质上都是 Redis 客户端和服务器端之间,或是主从节点之间为了传输命令数据而维护的。这些缓冲区一旦发生溢出,处理机制都是直接把客户端和服务器端的连接,或是主从节点间的连接关闭。网络连接关闭造成的直接影响,就是业务程序无法读写 Redis,或者是主从节点全量同步失败,需要重新执行。
  • 缓冲区溢出导致命令数据丢失:主节点上的复制积压缓冲区属于环形缓冲区,一旦发生溢出,新写入的命令数据就会覆盖旧的命令数据,导致旧命令数据的丢失,进而导致主从节点重新进行全量复制。

从本质上看,缓冲区溢出,无非就是三个原因:命令数据发送过快过大;命令数据处理较慢;缓冲区空间过小。明白了这个,我们就可以有针对性地拿出应对策略了。

  • 针对命令数据发送过快过大的问题,对于普通客户端来说可以避免 bigkey,而对于复制缓冲区来说,就是避免过大的 RDB 文件。
  • 针对命令数据处理较慢的问题,解决方案就是减少 Redis 主线程上的阻塞操作,例如使用异步的删除操作。
  • 针对缓冲区空间过小的问题,解决方案就是使用 client-output-buffer-limit 配置项设置合理的输出缓冲区、复制缓冲区和复制积压缓冲区大小。当然,我们不要忘了,输入缓冲区的大小默认是固定的,我们无法通过配置来修改它,除非直接去修改 Redis 源码。

 

0

面试题:Jvm的Hotspot的锁升级?

这边主要有三种锁的名词:偏向锁自旋锁重量级锁

之前《Java锁synchronized关键字原理的Mark Word理解》这篇文章有讲到synchronized的一些实现原理,就是在存储的Java对象的头信息里记录线程的一些信息,其中就有记录是否是偏向锁。

当线程A第一次拿到锁后,会在头信息里记录下线程A的id,然后当下次如果还是线程A拿到锁进来,那jvm就直接根据是否是偏向锁来决定是否允许它进来,也就是说这个时候其实并没有涉及到真正的锁,只是一个简单的判断。

当线程B准备竞争锁时,它会先进入到一个轻量级的锁——自旋锁,这时就相当于一个while循环(默认是10次),这时主要消耗的是cpu资源。

当自旋超过10次后,这时就会像线程C那样,开始升级申请操作系统的重量级锁,然后进入等待队列。

从中可以看出,当业务里有大量耗时长的线程时,是不适宜使用自旋锁的,因为那将非常消耗cpu资源。

0

面试题:Java线程里抛出异常会释放锁吗?

为了验证这个问题,写了一段代码来验证,它可以保证线程1先执行,如果线程1不抛出异常,那它应该一直循环输出,线程2根本进不来。

那如果抛出了异常,可以看看执行结果。

输出

+1

java动态代理面试题

题目

1.动态代理的应用场景?
2.aop就是代理吗?aop和代理的关系?
3.spring aop中代理有哪4种实现方式?区别?
4.代理底层操作的是什么?
5.如何查看某个class类的jvm虚拟机指令码?aspectj和instrumentation实现的逻辑是什么?它们和java proxy、cglib有什么区别?
6.动态代理技术栈图?

答案

  • 基于Class字节码透视java动态代理本质 https://www.youtube.com/watch?v=9uEIOpbi-lQ&list=PLtLteuiQZN4P_H0HcrqjgBAsRepMRnyQb
  • 问题5的答案:①aspectj和instrumentation是跳过代码层面直接在class文件里织入字节码指令,而java proxy和cglib是生成新的class。 ②前面两个是需要用到反射的。③共同点就是它们底层都是通过字节码实现的。 ④ 其中cglib和instrumentation对字节码的操作都是通过ASM实现的。java proxy 和 aspectj是自己操作字节码。
  • 动态代理技术栈图:

Java Proxy示例代码

使用javap -c ProxyTest.class查看字节码信息

0

面试题:软件多租户概念及几种实现方案

多租户概念

多租户技术或称多重租赁技术,简称SaaS,是一种软件架构技术,是实现如何在多用户环境下(此处的多用户一般是面向企业用户)共用相同的系统或程序组件,并且可确保各用户间数据的隔离性。

从定义中我们可以理解:多租户是一种架构,目的是为了让多用户环境下使用同一套程序,且保证用户间数据隔离。那么重点就很浅显易懂了,多租户的重点就是同一套程序下实现多用户数据的隔离。

数据隔离方案

多租户在数据存储上存在三种主要的方案,分别是:

  1. 独立数据库
  2. 共享数据库,独立 Schema
  3. 共享数据库,共享 Schema,共享数据表
(1)独立数据库

即一个租户一个数据库,这种方案的用户数据隔离级别最高,安全性最好,但成本较高。

优点:为不同的租户提供独立的数据库,有助于简化数据模型的扩展设计,满足不同租户的独特需求;如果出现故障,恢复数据比较简单。

缺点:增多了数据库的安装数量,随之带来维护成本和购置成本的增加。

(2)共享数据库,独立 Schema

多个或所有租户共享Database,但是每个租户一个Schema(也可叫做一个user)。底层库比如是:DB2、ORACLE等,一个数据库下可以有多个SCHEMA。

优点:为安全性要求较高的租户提供了一定程度的逻辑数据隔离,并不是完全隔离;每个数据库可支持更多的租户数量。

缺点:如果出现故障,数据恢复比较困难,因为恢复数据库将牵涉到其他租户的数据;

(3)共享数据库,共享 Schema,共享数据表

即租户共享同一个Database、同一个Schema,但在表中增加TenantID多租户的数据字段。这是共享程度最高、隔离级别最低的模式。

简单来讲,即每插入一条数据时都需要有一个客户的标识。这样才能在同一张表中区分出不同客户的数据,这也是我们系统目前用到的(tenant_id)

优点:三种方案比较,第三种方案的维护和购置成本最低,允许每个数据库支持的租户数量最多。

缺点:隔离级别最低,安全性最低,需要在设计开发时加大对安全的开发量; 数据备份和恢复最困难,需要逐表逐条备份和还原。

0

Java面试题:HashMap中当key为类时的注意事项

HashMap中,当key为类时,比较key是否相等需要重写equal() 和hashCode()这两个方法。

在HashMap中,如果key为类对象,则必须要重写hashCode() 和equal()这两个方法。Why?首先了解下未被重写的hashCode() 和equal()方法。

1.未被重写的hashCode() 和equal()方法

public int hashCode():HashCode是根类Obeject中的方法。默认情况下,Object中的hashCode() 返回对象的32位jvm内存地址。也就是说如果对象不重写该方法,则返回相应对象的32为JVM内存地址。 

public bollean equals():用于比较两个对象是否相同,它其实就是使用两个对象的内存地址在比较。Object类中的equals方法内部使用的就是==比较运算符。

2.在HashMap中,判断两个对象(key)是否相等的规则:

2.1.判断两个对象的hashCode()是否相等

如果不相等,认为两个对象也不相等,完毕

如果相等,转入2

2.2.判断两个对象的equals()是否相等

如果不相等,认为两个对象也不相等

如果相等,认为两个对象相等

3.为什么重载hashCode方法?

一般的地方不需要重载hashCode(),只有当类需要放在HashTable、HashMap、HashSet等等hash结构的集合时才会重载hashCode(),那么为什么要重载hashCode()呢?

如果不重载hashCode()方法,由于两个对象实例的内存地址不同,根据2.1,则判为不等。

重载之后,hashCode()方法变成判断其逻辑上是否相同,根据2.1,则判为相等。

4.为什么要重载equal方法?

因为Object的equal方法默认是两个对象的引用的比较,意思就是指向同一内存,地址则相等,否则不相等;如果你现在需要利用对象里面的值来判断是否相等,则重载equal方法。

5.代码示例

假设SysConfig是要作为hashmap的key的一个类

引申扩展:为什么hashmap里的key经常使用string,并且不需要重写hashcode和equals方法?

在《Java 编程思想》中有这么一句话:设计 hashCode() 时最重要的因素就是对同一个对象调用 hashCode() 都应该产生相同的值。String 类型的对象对这个条件有着很好的支持,因为 String 对象的 hashCode() 值是根据 String 对象的内容计算的,并不是根据对象的地址计算。

下面是 String 类源码中的 hashCode() 方法:String 对象底层是一个 final 修饰的 char 类型的数组,hashCode() 的计算是根据字符数组的每个元素进行计算的,所以内容相同的 String 对象会产生相同的散列码。

我们希望的是我们在用对象作为 key 时,我们在获取的时候也依然能够根据重新定义 key 获得对应的 value。就像下面这样,但是它却不能正常工作:这两个对象产生的散列码是不同的,所以在进行 equals() 判断的时候这两个对象被认定为是不同的对象,自然也就获取不到对应的 value。

如果你想获得 map 中存储的“123”,那么你只有把 test 对象作为 key 才能获取到,我们试图用一种简单的方法就能让它工作,但是这显然与我们的初衷相背驰。所以非 String 类型的数据类型在判断 key 相同时所需要的条件太过苛刻。

输出 null

但是 String 类型的对象就不一样了,内容相同的两个 String 对象具有相同的散列码,并且经过 equals() 判断后返回值为 true,所以在进行查找的时候它可以正常工作。

输出 123

总结:在使用 String 类型的对象做 key 时我们可以只根据传入的字符串内容就能获得对应存在 map 中的 value 值,而非 String 类型的对象在获得对应的 value 时需要的条件太过苛刻,首先要保证散列码相同,并且经过 equals() 方法判断为 true 时才可以获得对应的 value。

+1