Skip to content

Latest commit

 

History

History
1089 lines (747 loc) · 78.2 KB

面试题.md

File metadata and controls

1089 lines (747 loc) · 78.2 KB

IoC:

文章: wiki

调用者掌握着被调用者对象创建的控制权,这是强耦合的. Spring就是干了"中间人"这件事

Spring循环依赖问题:

文章: Spring的循环依赖面试题

三级缓存是用于处理AOP实现的,不考虑AOP情况下甚至单个缓存就够了. 工厂的缓存是为了延迟对实例化阶段生成的对象的代理.

Spring Bean生命周期:

实例化=>填充对象属性=>设置Aware=>初始化前置增强=>初始化Bean=>初始化后置增强=>使用Bean=>销毁

Spring启动流程:

实例化BeanFactory=>构建BeanFactory的前置准备=>注册默认的BeanPostProcessor以及特殊的Bean(系统级)=>BeanFactory后置增强(这里包含了BeanDefinitionRegistry增强)=>注册BeanPostProcessor=>调用onRefresh()钩子=>实例化所有非懒加载的单例Bean

Spring MVC是通过反射来调用@RequestBody对应的Method的

Spring的@Import注解实现就是个BeanDefinitionRegistryPostProcessor. 参看:

851491-20210417154011048-400718472.png


JUC

AQS:

独占锁和共享锁. 独占锁每次只能唤醒一个后继节点; 共享锁会一直唤醒所有后继节点,但由于独占锁和共享锁用的是同一个队列,所以唤醒后继节点中如果碰到了独占节点还是会停下来的(JUC里的ReentrantReadWriteLock就是这么干的,读写锁在一个队列里).

ReentrantLock:

公平锁和非公平锁其实差不了多少. 非公平锁只是在tryAcquire的时候先CAS,失败再入队列. 公平锁则是判断队列是否有Node.

ReentrantReadWriteLock:

文章: ReentrantReadWriteLock 读写锁详解

将锁分为了读写锁两种,读锁为共享模型,写锁为独占模型. 且两个共用一个AQS队列,读锁占state的高16位,写锁占state的低16位. 同一个线程中获取锁的话会进行锁降级,比如: 获取写锁,获取读锁,释放写锁,写锁可以降级成为读锁. 公平锁和非公平锁: 区别就是公平锁在获取锁时会排队,等到前面的节点唤醒它; 非公平锁不会排队,直接CAS抢占锁.

HashMap:

1.8的红黑树是当链表长度大于等于8时转的(put时). 并且在resize时小于等于6时转为链表. 1.7扩容时会导致环形链. 1.8改成了hash碰撞时直接插入元素,但还是会导致数据丢失.

ConcurrentHashMap:

文章: JDK1.8 ConcurrentHashMap

JDK1.7使用分段锁来保证在多线程下的性能。一次锁住一个桶, 默认将 hash 表分为 16 个桶,诸如 get,put,remove 等常用操作只锁(ReentrantLock#tryLock())当前需要用到的桶。这样,原来只能一个线程进入,现在却能同时有 16 个写线程执行,并发性能的提升是显而易见的. 另外,它还使用了一种不同的迭代方式。在这种迭代方式中,当iterator被创建后集合再发生改变就不再是抛出ConcurrentModificationException异常,取而代之的是在改变时 new 新的数据从而不影响原有的数据,iterator 完成后再将头指针替换为新的数据 ,这样 iterator 线程可以使用原来老的数据,而写线程也可以并发的完成改变

JDK 1.8读数据的时候无锁(但有Unsafe+Node的Volatile),写的时候有key冲突则是Node对象的synchronized锁,没有则是CAS. 扩容时不影响读取,采用Forward思路(node.hash=-1表示正在迁移),读时将迭代器对应数据进行转发到新的数组. 写时碰到了会帮助扩容. 并且扩容机制是通过多线程将扩容任务进行分段. 获取容器实际大小用mappingCount()方法. 它的读不用加锁,用的是CAS+volatile实现原子性.

StampedLock:

文章: StampedLock 读写锁

JDK1.8中加的,性能更强,不支持重入. 没看代码实现

ConcurrentLinkedQueue:

一种高速但非立即可见数据的线程安全链表,非立即可见是因为用的是UNSAFE.putOrderedObject而非UNSAFE.putObjectVolatile.

CopyOnWriteArrayList:

加、减节点不会在原来的节点上进行操作,会复制一份出来处理完后再覆盖原先的.

背后透露的思想:

  • 读写分离,读和写分开
  • 最终一致性
  • 使用另外开辟空间的思路,来解决并发冲突

DelayQueue:

内部队列实际上是一个PriorityQueue(优先级队列).节点元素实现按照节点过期时间作比较排序,最先过期的元素在数组最前. 拿的时候做个轮询时间的机制. 总的来说这个队列不是很常用.

LinkedBlockingDeque:

链表组成的双向阻塞队列,双向队列意味着可以从对头、对尾两端插入和移除元素. 读写共用一把锁,读取和存储失败时(空或满)会阻塞(用的两个Condition),等到链表满足条件会手动唤醒.

Unfafe.park和Object.wait区别

文章: Thread.sleep、Object.wait、LockSupport.park 区别

都是休眠,然后唤醒. 但是wait、notify方法有一个不好的地方,就是我们在编码的时候必须能保证wait方法比notify方法先执行。如果notify方法比wait方法晚执行的话,就会导致因wait方法进入休眠的线程接收不到唤醒通知的问题。而park、unpark则不会有这个问题,我们可以先调用unpark方法释放一个许可证,这样后面线程调用park方法时,发现已经许可证了,就可以直接获取许可证而不用进入休眠状态了.

ThreadLocal:

文章: 线程池里用ThreadLocal问题

线程池中的内存泄露:

ThreadLocal用的是一个Map结构,key是ThreadLocal,并且是从Thread对象里拿的Map,它的Entry继承了WeakReference<ThreadLocal>. 在线程池场景下并不一定会回收线程,导致弱引用也不会释放资源,所以需要手动回收掉.

Hash冲突:

和HashMap的处理方式不一样. 它是碰到有相同数组下标且不相等(Key != TableKey)时继续访问下一个index(算法: ((i + 1 < len) ? i + 1 : 0)),等于是环形访问数组并找到第一个有空位的下标后直接放入. 当然,如果全都满了则会resize()

线程池:

四种拒绝策略: 直接抛出异常、只用调用者所在线程来运行任务 、丢弃队列里的一个任务(poll),并调用execute()、不处理,丢弃掉.

Tomcat自定义的ThreadPoolExecutor: Tomcat实现了个更激进的线程池. 重写BlockingQueue对应offer方法,直接返回false,并定义RejectedExecutionHandler,触发拒绝策略的时候再把任务加入队列.

关于回收:

  1. 未调用shutdown(),RUNNING状态下全部任务执行完成的场景. 线程数量大于corePoolSize,线程超时阻塞,超时唤醒后CAS减少工作线程数,如果CAS成功,返回null,线程回收.
  2. 调用shutdown(),全部任务执行完成的场景.

synchronized关键字:

文章: Synchronized底层实现Synchronized介绍(先看这个)

对象头:

  • 当对象状态为偏向锁(biasable)时,mark word存储的是偏向的线程ID;
  • 当状态为轻量级锁(lightweight locked)时,mark word存储的是指向线程栈中Lock Record的指针;
  • 当状态为重量级锁(inflated)时,为指向堆中的monitor对象的指针.
  • 同步代码块:monitorenter 指令插入到同步代码块的开始位置,monitorexit 指令插入到同步代码块的结束位置,JVM 需要保证每一个monitorenter都有一个 monitorexit 与之相对应。任何对象都有一个 Monitor 与之相关联,当且一个Monitor 被持有之后,他将处于锁定状态。线程执行到 monitorenter 指令时,将会尝试获取对象所对应的 Monitor 所有权,即尝试获取对象的锁。
  • 同步方法:synchronized 方法则会被翻译成普通的方法调用和返回指令如:invokevirtualareturn 指令,在 VM 字节码层面并没有任何特别的指令来实现被synchronized 修饰的方法,而是在 Class 文件的方法表中将该方法的 access_flags 字段中的 synchronized 标志位置设置为 1,表示该方法是同步方法,并使用调用该方法的对象该方法所属的 Class 在 JVM 的内部对象表示 Klass 作为锁对象。

自旋锁>>偏向锁>>轻量锁>>重量锁

  • 自旋锁是基于对象锁的锁状态只会持续很短一段时间的场景下做的优化,减少了线程的阻塞和唤醒
  • 偏向锁提高一个对象在一段很长的时间内都只被一个线程用做锁对象场景下的性能.
  • 轻量锁是在没有多线程竞争的前提下,减少传统的重量级锁使用操作系统互斥量产生的性能消耗(通过自旋)
  • 重量锁通过对象内部的监视器(Monitor)实现,依赖于底层操作系统的 Mutex Lock
  • 锁消除是根据逃逸分析的数据支持来判断对象并发安全并消除显示锁
  • 锁粗化将多个连续的加锁、解锁操作连接在一起,扩展成一个范围更大的锁

synchronized和lock的区别:

synchronized是悲观锁,lock是乐观锁; synchronized不可设置超时; lock更灵活;

volatile关键字:

文章: 汇编层机器码层

用于多线程环境下的单次操作(单次读或者单次写); 可用于避免指令重排1. 可见性保证; 提供happens-before的保证,确保一个线程的修改能对其他线程是可见的;

本质是在告诉 JVM 当前变量在寄存器(工作内存)中的值是不确定的,需要从主存中读取

对应汇编: lock addl $0x0, (%rsp).
synchronized对应汇编: lock cmpxchg a,b,c. cmpxchg: 它是一个原子的loadcomparesave操作. Intel规定lock只能对单条指令起作用,所以lock才是保证真正排他功能的命令. lock: Ring Bus+MESI

happens-before原则:

文章: JMM详解

  1. 如果一个操作happens-before另一个操作,那么第一个操作的执行结果将对第二个操作可见,而且第一个操作的执行顺序排在第二个操作之前.
  2. 两个操作之间存在happens-before关系,并不意味着一定要按照happens-before原则制定的顺序来执行。如果重排序之后的执行结果与按照happens-before关系来执行的结果一致,那么这种重排序并不非法

Java内存模型(Java Memory Model,JMM):

01.png

线程之间的通信方式: 共享内存(JMM用的是它)和消息传递

内存屏障,又称内存栅栏,是一组处理器指令,用于实现对内存操作的顺序限制:

当基于共享可变状态的内存操作被重新排序时,程序可能行为不定。一个线程写入的数据可能被其他线程可见,原因是数据写入的顺序不一致。适当的放置内存屏障,通过强制处理器顺序执行待定的内存操作来避免这个问题。


MySQL

逻辑分层:

client =>连接层 =>服务层 =>引擎层

MRR(Multi-Range Read Optimization): 淘宝数据库内核月报

索引下推:

文章: MySQL是怎样运行的

是个优化,默认情况下存储引擎通过索引检索到数据然后给MySQL服务端,服务端来判断数据是否符合条件. 使用索引下推优化时会将命中索引的条件传到引擎层并交由引擎层筛选数据返回给MySQL服务端.

为什么要有binlog和redolog同时存在:

文章: mysql 为什么不能用binlog来做crash-save 扩展文章: Aries 算法ARIES事务恢复IBM小红书

首先redolog不可能代替的了binlog,因为redolog是固定容量的循环写.

binlog也不能代替redolog,binlog记录着对数据行的插入/更新/删除,是一种逻辑日志,就是说,它记录着数据行的字段值和/或其新旧变化,但是不记录事务对数据页的改动,这样细致的改动只记录在redolog中。 当一个事务做插入/更新/删除时,其实涉及到的数据页改动非常细致和复杂,包括行的字段改动以及行头部以及数据页头部的改动,甚至b+tree会因为插入一行而发生若干次页面分裂,那么事务也会把所有这些改动记录下来到redolog中。总之,任何对数据页的改动都会记录到redolog中。 这样才能做事务恢复。因为数据库系统进程crash时刻,磁盘上面页面镜像可以非常混乱,其中有些页面含有一些正在运行着的事务的改动,而一些已提交的事务的改动并没有刷上磁盘。事务恢复过程可以理解为是要把没有提交的事务的页面改动都去掉,并把已经提交的事务的页面改动都加上去 这样一个过程。这些信息,都是binlog中没有记录的,只记录在了存储引擎的redolog中.

为什么会有undo log、redo log、binlog:

文章: 写入放大

binlog: 用于备份、同步和恢复数据. undo log: 是为了事务回滚或者Crash Recovery时将未成功执行的事务修改数据进行回滚. redo log: 是为了磁盘I/O性能考虑的,理想情况下是在事务提交那一刻稳定存储,这种策略称之为force刷新策略,但是会严重影响性能(产生很多小的磁盘随机I/O和写放大),因此在现实中的数据库大多使用no-force策略(即事务提交时不会强制刷新数据缓冲page到磁盘). 同时它也可以帮助进行数据库系统故障回复.

注意,这个redo log和bin log的介绍不是针对InnoDB的,是数据库理论.

更新数据时二阶段提交的具体流程:

  1. InnoDB进入Prepare阶段,并且write/sync redo log,写redo log,将事务的XID写入到redolog中,bin log不作任何操作
  2. 进行write/sync bin log,写bin log,也会把XID写入到bin log
  3. 调用InnoDB引擎的Commit完成事务的提交,将Commit信息写入到redo log中

细节:

  1. redolog为prepare状态,binlog没有写入,MySQL在恢复的时候会丢弃这条prepared日志.
  2. binlog写完之后,在把redolog改为commit之前,MySQL崩溃,MySQL在恢复的时候会走正常流程.

binlog刷盘时机:

对于InnoDB,在事务提交时才会记录binlog,此时记录在内存中,通过sync_binlog控制刷盘时机,取值0-N. 0: 不去强制要求,由系统自行判断何时写入磁盘. 1: 每次commit的时候都要将binlog写入磁盘. PS: 5.7.7默认 N: 每N个事务,才会将binlog写入磁盘.

MySQL8新特性:

文章: 来自51CTO这个更好

新特性:

  1. 默认字符由latin1(utf8mb3)变为utf8mb4.
  2. 系统表中的MyISAM引擎全部换成InnoDB. 比如: event、binlog_index、user.
  3. DDL(Data Definition Language)原子化.
  4. 参数修改持久化.
    4.1 介绍: MySQL 8.0版本支持在线修改全局参数并持久化,通过加上PERSIST关键字,可以将修改的参数持久化到新的配置文件(mysqld-auto.cnf)中,重启MySQL时,可以从该配置文件获取到最新的配置参数. 例: set PERSIST expire_logs_days=10; 4.2 说明: 系统会在数据目录下生成一个包含json格式的mysqld-auto.cnf文件,当my.cnfmysql-auto.cnf同时存在时,后者具有更高优先级.
  5. 新增降序索引. 对于group by字段不再隐式排序,如果需要排序,必须显示加上order by子句(8.0版本以前用了个file sort来隐式根据ID排序,现在去掉了,所以之前依赖这条规则的SQL会遭殃).
  6. json特性增强: MySQL 8大幅改进了对JSON的支持,添加了基于路径查询参数从JSON字段中抽取数据的JSON_EXTRACT() 函数,以及用于将数据分别组合到JSON数组和对象中的JSON_ARRAYAGG()和JSON_OBJECTAGG()聚合函数.
  7. redo&undo日志加密.

binlog里面存了哪些类型的数据:

  • statement: 记录的是修改SQL语句;
    • 优点: 更少的binlog日志量;
    • 缺点: sysdate()等函数的数据不一致;
  • row: 记录的是每行实际数据的变更;
    • 优点: 上面缺点的解决,不会错误调用存过.
    • 缺点: 大量日志.
  • mixed: statement和row模式的混合,一般复制用statement,statement无法复制的用row.

隔离级别:

文章: MySQL RC隔离级别会导致的BUG性能基准测试

  • READ UNCOMMITTED(未提交读)
  • READ COMMITTED(已提交读)
  • 【默认】REPEATABLE READ(可重复读)
  • SERIALIZABLE(可串行化)

用RC的好处:

  • 原因一: RR级别并发时会因为gap-lock导致死锁几率比RC大(PS: RR的gap dead lock比较隐晦,但没办法,需要保证处理幻读).
  • 原因二: RR级别条件未命中索引会锁表,RC只锁行(PS: 也是gap lock的问题).
  • 原因三: 在RC隔离级别下,半一致性读(semi-consistent)特性增加了update操作的并发性(RR只生成一次ReadView,RC生成多次).

半一致性读: 当 Update 语句的 where 条件中匹配到的记录已经上锁,会再次去 InnoDB 引擎层读取对应的行记录,判断是否真的需要上锁(第一次需要由 InnoDB 先返回一个最新的已提交版本)

注意: binlog在RC级别下用的是row格式(而且必须用),5.7.7后RR默认也是row.

MySQL半同步和异步同步:

文章: MySQL半同步复制详解

MySQL 复制默认是异步的,主库写入事务在生成 Events 并写入到 Binlog 文件之后,写入线程是不等待的。在这种情况下,如果主库挂了,有可能没有任何一个从库可以收到已经在主库提交的事务,而此时如果将业务从主库切换到了从库,则可能会导致从库丢失主库上面发生的很多修改.

半同步复制具体有五点特征:

  1. 从库会在连接到主库时告诉主库,它是不是配置了半同步
  2. 如果半同步复制在主库端是开启了的,并且至少有一个半同步复制的从库节点,那么此时主库的事务线程在提交时会被阻塞并等待. 结果有两种可能: 要么至少一个从库节点通知它已经收到了所有这个事务的 Binlog 事件;要么一直等待直到超过配置的某一个时间点为止,而此时,半同步复制将自动关闭,转换为异步复制.
  3. 从库节点只有在接收到某一个事务的所有 Binlog,将其写入并 Flush 到 Relay Log 文件之后,才会通知对应主库上面的等待线程
  4. 如果在等待过程中,等待时间已经超过了配置的超时时间,没有任何一个从节点通知当前事务,那么此时主库会自动转换为异步复制,当至少一个半同步从节点赶上来时,主库便会自动转换为半同步方式的复制
  5. 半同步复制必须是在主库和从库两端都开启时才行,如果在主库上没打开,或者在主库上开启了而在从库上没有开启,主库都会使用异步方式复制

PS: 当然,也可以配置全同步,不过这样性能很差

InnoDB和MyISAM区别:

  1. MyISAM不支持事务
  2. MyISAM所有索引都是二级索引,所以数据和索引是分开存储的,而且没有表空间的说法.
  3. InnoDB不保存表的具体行数,MyISAM 用一个变量保存了整个表的行数
  4. InnoDB 最小的锁粒度是行锁,MyISAM 最小的锁粒度是表锁

一次性大量的数据如何删除:

  1. 删除索引
  2. 删除需要删除的数据
  3. 重新建立索引

其它:

  1. MySQL读写分离架构时编码需要注意时延问题,有时会导致先写入后立马读会读不到的问题,所以特殊情况下插入查询需要在一个事务里

Mongo和Redis比较:

  1. Redis全部存在内存,定期写入磁盘,当内存不够时用LRU删除数据; Mongo优先存在内存,不够时只将热点数据放入内存,其他数据存在磁盘.
  1. Redis只支持基本数据结构,包括hash、set、list等; MongoDB属于文档数据库,支持丰富的数据表达、索引,最类似关系型数据库,支持的查询语言非常丰富.

PS: Mongo设计中最主要的是解决无结构数据的存储.


分布式ID

文章: Leaf(美团开源框架)

雪花算法数据构成(64字节): 1bit: 因为二进制中最高位是符号位,1表示负数,0表示正数.生成的ID都是正整数,所以最高位固定为0. 41bit-时间戳: 精确到毫秒级,41位的长度可以使用69年.时间位还有一个很重要的 作用是可以根据时间进行排序. 10bit-工作机器id: 10位的机器标识,10位的长度最多支持部署1024个节点. 12bit-序列号: 序列号即一系列的自增id,可以支持同一节点同一毫秒生成4095个id序号.

时间回溯问题:

  1. 时间差距不大时等待就好了
  2. 用逻辑时间
  3. 差距大的直接踢掉
  4. 不要时间回溯,采用慢慢追上的方式更新时间

Redis

Sentinel:

也是个Redis Server,会通过与被监控的Master通信来拿Slave,以及通过监听Channel来获取其他Sentinel. Master客观下线后会通过选举(类似Raft)来选出处理下线操作的Sentinel Leader

获取所有Key:

key *会阻塞线程,scan单词不会阻塞太长(scan本身也是阻塞命令,但它可以限制大小)

线程模型:

6.0以前socketevent processor都是单线程; 6.0开始:

  1. 主线程 epoll_wait 一段时间,收集 N 个读事件后交给 I/O 线程池去处理读 socket 和解析请求
  2. 主线程阻塞等待 I/O 线程池完成 N 个读事件
  3. 主线程把解析出来的命令进行统一的处理
  4. 主线程把处理的结果放入缓冲区,又交给 I/O 线程池处理写 socket
  5. 主线程阻塞等待 I/O 线程池完成

9e9adb183f51be8c67e9d7c053a1778f.png

610ede68e9d331a9616610eda0c99892.png

关于单线程的redis如何利用多核cpu机器: 多开几个Redis服务,然后集群.

一次请求流程:

建立连接:

  1. 首先,redis 服务端进程初始化的时候,会将 server socket 的 AE_READABLE 事件与连接应答处理器关联。
  2. 客户端 socket01 向 redis 进程的 server socket 请求建立连接,此时 server socket 会产生一个 AE_READABLE 事件,IO 多路复用程序监听到 server socket 产生的事件后,将该 socket 压入队列中。
  3. 文件事件分派器从队列中获取 socket,交给连接应答处理器。
  4. 连接应答处理器会创建一个能与客户端通信的 socket01,并将该 socket01 的 AE_READABLE 事件与命令请求处理器关联。

执行一个set请求:

  1. 客户端发送了一个 set key value 请求,此时 redis 中的 socket01 会产生 AE_READABLE 事件,IO 多路复用程序将 socket01 压入队列。
  2. 此时事件分派器从队列中获取到 socket01 产生的 AE_READABLE 事件,由于前面 socket01 的 AE_READABLE 事件已经与命令请求处理器关联。
  3. 因此事件分派器将事件交给命令请求处理器来处理。命令请求处理器读取 socket01 的 key value 并在自己内存中完成 key value 的设置。
  4. 操作完成后,它会将 socket01 的 AE_WRITABLE 事件与命令回复处理器关联。
  5. 如果此时客户端准备好接收返回结果了,那么 redis 中的 socket01 会产生一个 AE_WRITABLE 事件,同样压入队列中。
  6. 事件分派器找到相关联的命令回复处理器,由命令回复处理器对 socket01 输入本次操作的一个结果,比如 ok,之后解除 socket01 的 AE_WRITABLE 事件与命令回复处理器的关联。

2136379-20200830233422768-1205881078.png

7种淘汰算法:

  • volatile-lru: 从已设置过期时间的数据集中挑选最近最少使用的数据淘汰.
  • volatile-ttl: 从已设置过期时间的数据集中挑选将要过期的数据淘汰.
  • volatile-random: 从已设置过期时间的数据集中任意选择数据淘汰.
  • volatile-lfu: 从已设置过期时间的数据集挑选使用频率最低的数据淘汰.
  • allkeys-lru: 从数据集中挑选最近最少使用的数据淘汰.
  • allkeys-lfu: 从数据集中挑选使用频率最低的数据淘汰.
  • allkeys-random: 从数据集(server.db[i].dict)中任意选择数据淘汰
  • no-enviction(驱逐): 禁止驱逐数据,这也是默认策略.意思是当内存不足以容纳新入数据时,新写入操作就会报错,请求可以继续进行,线上任务也不能持续进行,采用no-enviction策略可以保证数据不被丢失.

PS: 相关配置: maxmemory(内存阈值)、maxmemory_policy(淘汰策略)、maxmemory_samples(采样数据量)

rewrite:

bgrewriteaof用于压缩aof文件. 先fork子进程用来重新生成aof文件,为了处理父进程的增量数据问题加了个aof_buf, 当子进程完成AOF重写工作之后,它会向父进程发送一个信号,父进程接到信号后会调用一个信号处理函数,它会:

  1. 将AOF重写缓冲区中的所有内容写入到新的AOF文件中,这时新AOF文件所保存的数据库状态将和服务器当前的数据库状态一致.
  2. 将新的AOF文件进行改名,原子地(atomic)覆盖现有的AOF文件,完成新旧两个AOF文件的替换.在调用信号处理函数时会对服务器进程(父进程)造成阻塞,保证了数据一致性.

RedLock:

文章: RedLock和Zookeeper比较

流程:

  1. 获取当前时间(单位是毫秒).
  2. 轮流用相同的key和随机值在N个节点上请求锁,在这一步里,客户端在每个master上请求锁时,会有一个和总的锁释放时间相比小的多的超时时间.比如如果锁自动释放时间是10秒钟,那每个节点锁请求的超时时间可能是5-50毫秒的范围,这个可以防止一个客户端在某个宕掉的master节点上阻塞过长时间,如果一个master节点不可用了,我们应该尽快尝试下一个master节点.
  3. 客户端计算第二步中获取锁所花的时间,只有当客户端在大多数master节点上成功获取了锁,而且总共消耗的时间不超过锁释放时间(关键),这个锁就认为是获取成功了.
  4. 如果锁获取成功了,那现在锁自动释放时间就是最初的锁释放时间减去之前获取锁所消耗的时间.
  5. 如果锁获取失败了,不管是因为获取成功的锁不超过一半(N/2+1)还是因为总消耗时间超过了锁释放时间,客户端都会到每个master节点上释放锁,即便是那些他认为没有获取成功的锁.

PS: zookeeper的watch机制,客户端试图创建znode的时候,发现它已经存在了,这时候创建失败,那么进入一种等待状态,当znode节点被删除的时候,zookeeper通过watch机制通知它,这样它就可以继续完成创建操作(获取锁).这可以让分布式锁在客户端用起来就像一个本地的锁一样: 加锁失败就阻塞住,直到获取到锁为止.这套机制,redis无法实现

Redssion:

文章: 功能列表

优化:

文章: Hash Tag--用于指定key中的某部分字符串来计算hash值

为什么Redis哈希槽是16384(2^14)个:

发送心跳包时需要把所有的槽放到这个心跳包里,16384压缩后(bitmap)是2K,65535压缩后是8K,作者认为不会有这么多的集群,这样做不划算

应用场景:

  • 权限
  • 心跳
  • 微信授权
  • 文件淘汰算法
  • 设备影子

JVM

引用类型:

强引用(strong reference): 普通对象引用(如new 一个对象),只要还有强引用指向一个对象,就表明此对象还"活着"。在强引用面前,即使JVM内存空间不足,JVM宁愿抛出OutOfMemoryError运行时错误(OOM),让程序异常终止,也不会靠回收强引用对象来解决内存不足的问题.

软引用(soft reference): 当内存空间足够的时候,垃圾回收器不会回收它。只有当JVM认定内存空间不足时才会去回收软引用指向的对象.
PS: 一旦失去最后一个强引用,就会被 GC 回收

弱引用: 当所引用的对象在 JVM 内不再有强引用时, GC 后 weak reference 将会被自动回收.

幻像引用|虚引用(phantom reference): 如果一个对象仅持有虚引用,就相当于没有任何引用一样,在任何时候都可能被垃圾回收器回收

常见内存泄漏场景:

  1. 未关闭的资源
  2. 非静态内部类
  3. ThreadLocal乱用

JVM结构:

01.jpg

  • 类加载器: 在 JVM 启动时或者类运行时将需要的class加载到JVM中.
  • 运行时数据区: 将内存划分成若干个区以模拟实际机器上的存储、记录和调度功能模块,如实际机器上的各种功能的寄存器或者PC指针的记录器等.
  • 执行引擎: 执行引擎的任务是负责执行class文件中包含的字节码指令,相当于实际机器上的CPU.
  • 本地方法调用: 调用C或C++实现的本地方法的代码返回结果.

运行内存的分类:

02.png

  • 程序计数器: Java 线程私有,类似于操作系统里的 PC 计数器,它可以看做是当前线程所执行的字节码的行号指示器。
    • 如果线程正在执行的是一个 Java 方法,这个计数器记录的是正在执行的虚拟机字节码指令的地址;如果正在执行的是 Native 方法,这个计数器值则为空(Undefined)。
    • 此内存区域是唯一一个在 Java 虚拟机规范中没有规定任何OutOfMemoryError情况的区域。

    PS : 多线程场景时它可以用来恢复线程上次执行进度.

  • 虚拟机栈(栈内存):Java线程私有,虚拟机栈描述的是 Java 方法执行的内存模型:
    • 每个方法在执行的时候,都会创建一个栈帧用于存储局部变量、操作数、动态链接、方法出口等信息。
    • 每个方法调用都意味着一个栈帧在虚拟机栈中入栈到出栈的过程。
  • 本地方法栈 :和 Java 虚拟机栈的作用类似,区别是该区域为 JVM 提供使用 Native 方法的服务。
  • 堆内存(线程共享):所有线程共享的一块区域,垃圾收集器管理的主要区域。
    • 目前主要的垃圾回收算法都是分代收集算法,所以 Java 堆中还可以细分为:新生代和老年代;再细致一点的有 Eden 空间、From Survivor 空间、To Survivor 空间等,默认情况下新生代按照 8:1:1 的比例来分配。
    • 根据 Java 虚拟机规范的规定,Java 堆可以处于物理上不连续的内存空间中,只要逻辑上是连续的即可,就像我们的磁盘一样。
  • 方法区(线程共享):各个线程共享的一个区域,用于存储虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。
    • 虽然 Java 虚拟机规范把方法区描述为堆的一个逻辑部分,但是它却有一个别名叫做 Non-Heap(非堆),目的应该是与 Java 堆区分开来。
    • 运行时常量池:是方法区的一部分,用于存放编译器生成的各种字面量和符号引用

JDK8 的改变:

  • 废弃 PermGen(永久代),新增 Metaspace(元数据区)。MetaSpace 大小默认没有限制,一般根据系统内存的大小。JVM 会动态改变此值.
    • -XX:MetaspaceSize : 分配给类元数据空间(以字节计)的初始大小。此值为估计值,MetaspaceSize 的值设置的过大会延长垃圾回收时间。垃圾回收过后,引起下一次垃圾回收的类元数据空间的大小可能会变大
    • -XX:MaxMetaspaceSize :分配给类元数据空间的最大值,超过此值就会触发Full GC 。此值默认没有限制,但应取决于系统内存的大小,JVM 会动态地改变此值
  • 方法区在 Metaspace 中,方法区都是一个概念的东西。 PS: 在 HotSpot 上把 GC 分代收集扩展至方法区,或者说使用永久带来实现方法区.
  • 字符串常量存放到堆内存中。

为什么要废弃永久代:

  1. 由于永久代内存经常不够用或发生内存泄露,爆出异常java.lang.OutOfMemoryError: PermGen : 1.1: 字符串存在永久代中,容易出现性能问题和内存溢出。 1.2: 类及方法的信息等比较难确定其大小(动态加载类),因此对于永久代的大小指定比较困难,太小容易出现永久代溢出,太大则容易导致老年代溢出(主要是为了降低FullGC)。
  2. 永久代会为 GC 带来不必要的复杂度,并且回收效率偏低。

PS: 元空间用的是直接内存

Java 内存堆和栈区别:

  • 栈内存用来存储基本类型的变量和对象的引用变量;堆内存用来存储Java中的对象,无论是成员变量,局部变量,还是类变量,它们指向的对象都存储在堆内存中。
  • 栈内存归属于单个线程,每个线程都会有一个栈内存,其存储的变量只能在其所属线程中可见,即栈内存可以理解成线程的私有内存;堆内存中的对象对所有线程可见。堆内存中的对象可以被所有线程访问。
  • 如果栈内存没有可用的空间存储方法调用和局部变量,JVM 会抛出 java.lang.StackOverFlowError 错误;如果是堆内存没有可用的空间存储生成的对象,JVM 会抛出 java.lang.OutOfMemoryError 错误。
  • 栈的内存要远远小于堆内存,如果你使用递归的话,那么你的栈很快就会充满。-Xss 选项设置栈内存的大小,-Xms 选项可以设置堆的开始时的大小。

总结: JVM 中堆和栈属于不同的内存区域,使用目的也不同。栈常用于保存方法帧和局部变量,而对象大部分是在堆上分配。栈通常都比堆小,也不会在多个线程之间共享,而堆被整个 JVM 的所有线程共享。

Java对象创建的过程:

06.png

Java 中对象的创建就是在堆上分配内存空间的过程,此处说的对象创建仅限于 new 关键字创建的普通 Java 对象,不包括数组对象的创建

  1. 检测类是否被加载:

    当虚拟机遇到 new 指令时,首先先去检查这个指令的参数是否能在常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已被加载、解析和初始化过。如果没有,就执行类加载过程。

  2. 为对象分配内存:

    类加载完成以后,虚拟机就开始为对象分配内存,此时所需内存的大小就已经确定了。只需要在堆上分配所需要的内存即可。

    具体的分配内存有两种情况:第一种情况是内存空间绝对规整,第二种情况是内存空间是不连续的:

    • 对于内存绝对规整的情况相对简单一些,虚拟机只需要在被占用的内存和可用空间之间移动指针即可,这种方式被称为"指针碰撞"。
    • 对于内存不规整的情况稍微复杂一点,这时候虚拟机需要维护一个列表,来记录哪些内存是可用的。分配内存的时候需要找到一个可用的内存空间,然后在列表上记录下已被分配,这种方式成为"空闲列表"。

    多线程并发时会出现正在给对象 A 分配内存,还没来得及修改指针,对象 B 又用这个指针分配内存,这样就出现问题了。解决这种问题有两种方案:

    • 第一种,是采用同步的办法,使用 CAS 来保证操作的原子性。
    • 另一种,是每个线程分配内存都在自己的空间内进行,即是每个线程都在堆中预先分配一小块内存,称为本地线程分配缓冲(Thread Local Allocation Buffer, TLAB,栈上分配),分配内存的时候再TLAB上分配,互不干扰。可以通过 -XX:+/-UseTLAB 参数决定。
  3. 为分配的内存空间初始化零值:

    对象的内存分配完成后,还需要将对象的内存空间都初始化为零值,这样能保证对象即使没有赋初值,也可以直接使用。

  4. 对对象进行其他设置:

    分配完内存空间,初始化零值之后,虚拟机还需要对对象进行其他必要的设置,设置的地方都在对象头中,包括这个对象所属的类,类的元数据信息,对象的 hashcode ,GC 分代年龄等信息。

  5. 执行 init 方法:

    执行完上面的步骤之后,在虚拟机里这个对象就算创建成功了,但是对于 Java 程序来说还需要执行 init 方法才算真正的创建完成,因为这个时候对象只是被初始化零值了,还没有真正的去根据程序中的代码分配初始值,调用了 init 方法之后,这个对象才真正能使用。

下面是个简单的总结图:

07.png

对象的内存布局:

  • 对象头:对象头包括两部分信息。
    • 第一部分,是存储对象自身的运行时数据,如哈希码,GC 分代年龄,锁状态标志,线程持有的锁等等。
    • 第二部分,是类型指针,即对象指向类元数据的指针。
  • 实例数据:就是数据。
  • 对齐填充:不是必然的存在,就是为了对齐。

发生OutOfMemoryError的可能:

  • Java 堆溢出: 有很大可能是内存溢出,使用 MAT 进行分析
  • 虚拟机栈和本地方法栈溢出:
    • 虚拟机在扩展栈时无法申请到足够的内存空间
    • -XX:MaxMetaspaceSize=10m限制了增长上限
  • 方法区和运行时常量池(元数据区)溢出:
  • 本机直接内存(DirectMemory)溢出

判断一个对象是否已经死去:

  1. 引用计数: 每个对象有一个引用计数属性,新增一个引用时计数加 1 ,引用释放时计数减 1 ,计数为 0 时可以回收。此方法简单,无法解决对象相互循环引用的问题。目前在用的有 Python、ActionScript3 等语言.
  2. 可达性分析(Reachability Analysis): 从 GC Roots 开始向下搜索,搜索所走过的路径称为引用链。当一个对象到 GC Roots 没有任何引用链相连时,则证明此对象是不可用的。不可达对象。目前在用的有 Java、C# 等语言。

什么样的对象可以作为GC roots:

文章: GC roots

  1. 所有Java线程当前活跃的栈帧里指向GC堆里的对象的引用; 换句话说,当前所有正在被调用的方法的引用类型的参数/局部变量/临时值.
  2. VM的一些静态数据结构里指向GC堆里的对象的引用.
  3. JNI handles,包括global handles和local handles 3.1(看情况)所有当前被加载的Java类 3.2(看情况)Java类的引用类型静态变量 3.3(看情况)Java类的运行时常量池里的引用类型常量(String或Class类型) 3.4(看情况)String常量池(StringTable)里的引用

垃圾收集算法:

文章: JVM垃圾回收算法

  1. 引用计数: 实时性高. 无法解决循环依赖问题.(JVM不用它)

  2. 标记清除: 在标记阶段,首先通过根节点,标记所有从根节点开始的可达对象。因此,未被标记的对象就是未被引用的垃圾对象。然后,在清除阶段,清除所有未被标记的对象

    缺点:

    1. 效率问题,标记和清除两个过程的效率都不高(STW多)。
    2. 空间问题,标记清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致以后在程序运行过程中需要分配较大的对象时,无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作。
  3. 标记压缩|整理: 跟标记清除差不多,清理的时候将存活对象压缩到内存的一端,解决内存碎片问题.

    缺点: 比标记清除效率都低,因为还加了个移动内存操作.

  4. 复制算法: 将原有内存一分为二,每次只用其中的一块,垃圾回收时将存活的对象直接放入另一个空间中.

    缺点:

    1. 浪费一半的内存空间
    2. 在存活对象较多的情况下(老年代)效率差.
  5. 分代收集算法: 根据对象存活周期的不同将内存划分为几块,它把 Java 堆分为新生代和老年代,然后根据各个年代的特点采用最适当的收集算法:

    • 在新生代中,每次垃圾收集都发现有大批对象死去,只有少量存活,就选用复制算法.
    • 而老年代中,因为对象存活率高,没有额外空间对它进行分配担保,就必须使用:"标记清理"或者"标记整理"算法来进行回收.

fec52af474f1250831d46b541e0fe7a9.png

  • 图的左半部分是未回收前的内存区域,右半部分是回收后的内存区域。
  • 对象分配策略:
    • 对象优先在 Eden 区域分配,如果对象过大直接分配到 Old 区域。
    • 长时间存活的对象进入到 Old 区域。
  • 改进自复制算法:
    • 现在的商业虚拟机都采用这种收集算法来回收新生代,IBM 公司的专门研究表明,新生代中的对象 98% 是"朝生夕死"的,所以并不需要按照 1:1 的比例来划分内存空间,而是将内存分为一块较大的 Eden 空间和两块较小的 Survivor 空间,每次使用 Eden 和其中一块 Survivor 。当回收时,将Eden和Survivor中还存活着的对象一次性地复制到另外一块 Survivor 空间上,最后清理掉Eden和刚才用过的Survivor空间。
    • HotSpot 虚拟机默认Eden和2块Survivor的大小比例是 8:1:1,也就是每次新生代中可用内存空间为整个新生代容量的90%(80%+10%),只有10%的内存会被"浪费"。当然,98% 的对象可回收只是一般场景下的数据,我们没有办法保证每次回收都只有不多于10%的对象存活,当Survivor空间不够用时,需要依赖其他内存(这里指老年代)进行分配担保(Handle Promotion)。

会进老年代的几种情况:

  • Java对象是有对象头的,对象头的Mark word里有age,当进入Eden区的时,age=0,然后每进入一次Survivor区的时候 ,age都自增1,当 age = 15 的时候,该对象就进入老年代。
  • 老年代担保:意思是当Eden区的不可回收的对象占用的容量大于一个Survivor区的容量的的时候,那么一部分对象会进入Survivor区,而剩下的对象会进入老年代
  • 大对象会进入老年代,大的String,或者大的数组等等。当进入Eden区的时候,可能Eden区可用的内存不多了,进来的话,直接会造成Eden区的垃圾回收,每次都有大对象进入Eden区的话会造成新生代频繁的垃圾回收(也叫做minor GC),这样不好,所以大对象就进入到老年代。
  • 在Survivor区的对象,举个例子:当对象头中 age = 6 的所有的对象的容量大于Survivor区的容量的一半的时候,这是就会将所有age >=6 的对象将会进入老年代.

垃圾收集器:

文章: 7种垃圾收集器

PS: 如果说收集算法是内存回收的方法论,那么垃圾收集器就是内存回收的具体实现.

  • 新生代收集器
    • Serial 收集器
      • 它在进行垃圾收集时,必须暂停其他所有的工作线程,直至Serial收集器收集结束为止
    • ParNew 收集器(Serial 收集器的多线程版)
    • Parallel Scavenge 收集器
      • CMS等收集器的关注点是尽可能缩短垃圾收集时用户线程的停顿时间,而Parallel Scavenge收集器的目标是达到一个可控制的吞吐量
  • 老年代收集器
    • Serial Old 收集器(Serial 收集器的老年代版本)
    • Parallel Old 收集器(Parallel Scavenge 收集器的老年代版本)
    • CMS 收集器
      • 获取最短回收停顿时间为目标的收集器,它是基于"标记-清除"算法实现
  • 新生代 + 老年代收集器
    • G1 收集器
    • ZGC 收集器

PS:

  • 吞吐量: CPU用于运行用户代码的时间CPU总消耗时间的比值. 即: 吞吐量 = 运行用户代码时间 /(运行用户代码时间 + 垃圾收集时间)
收集器 串行、并行or并发 新生代/老年代 算法 目标 适用场景
Serial 串行 新生代 复制算法 响应速度优先 单CPU环境下的Client模式
Serial Old 串行 老年代 标记-整理 响应速度优先 单CPU环境下的Client模式、CMS的后备预案
ParNew 并行 新生代 复制算法 响应速度优先 多CPU环境时在Server模式下与CMS配合
Parallel Scavenge 并行 新生代 复制算法 吞吐量优先 在后台运算而不需要太多交互的任务
Parallel Old 并行 老年代 标记-整理 吞吐量优先 在后台运算而不需要太多交互的任务
CMS 并发 老年代 标记-清除 响应速度优先 集中在互联网站或B/S系统服务端上的Java应用
G1 并发 both 标记-整理+复制算法 响应速度优先 面向服务端应用,将来替换CMS

3b3c42d2.jpg

CMS收集器工作的整个流程分为以下4个步骤:

  • 初始标记(CMS initial mark):仅仅只是标记一下GC Roots能直接关联到的对象,速度很快,需要"Stop The World"。
  • 并发标记(CMS concurrent mark):进行GC Roots Tracing的过程,在整个过程中耗时最长。
  • 重新标记(CMS remark):为了修正并发标记期间因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间一般会比初始标记阶段稍长一些,但远比并发标记的时间短。此阶段也需要“Stop The World”。
  • 并发清除(CMS concurrent sweep)

PS: 默认新生代和老年代的比例为1:2,该值可以通过参数 –XX:NewRatio 来指定

f60599b2.png

什么情况下进行Young GC:

  • eden空间不足

什么情况下进行Full GC:

文章: All you need to know about Serial Garbage Collector

Full GC是单线程的,全程Stop The Word

  • 在执行 Young GC 之前,JVM 会进行空间分配担保——如果老年代的连续空间小于新生代对象的总大小(或历次晋升的平均大小),则触发一次 Full GC 。
  • 大对象直接进入老年代,从年轻代晋升上来的老对象,尝试在老年代分配内存时,但是老年代内存空间不够。
  • 显式调用 System#gc()
  • jmap –dump:live的内存信息时

CMS垃圾收集器缺点:

  • 对CPU资源非常敏感: 其实,面向并发设计的程序都对CPU资源比较敏感。在并发阶段,它虽然不会导致用户线程停顿,但会因为占用了一部分线程(或者说CPU资源)而导致应用程序变慢,总吞吐量会降低。CMS默认启动的回收线程数是(CPU数量+3)/4,也就是当CPU在4个以上时,并发回收时垃圾收集线程不少于25%的CPU资源,并且随着CPU数量的增加而下降。但是当CPU不足4个时(比如2个),CMS对用户程序的影响就可能变得很大,如果本来CPU负载就比较大,还要分出一半的运算能力去执行收集器线程,就可能导致用户程序的执行速度忽然降低了50%,其实也让人无法接受。
  • 无法处理浮动垃圾(Floating Garbage): 可能出现“Concurrent Mode Failure”失败而导致另一次Full GC的产生。由于CMS并发清理阶段用户线程还在运行着,伴随程序运行自然就还会有新的垃圾不断产生。这一部分垃圾出现在标记过程之后,CMS无法再当次收集中处理掉它们,只好留待下一次GC时再清理掉。这一部分垃圾就被称为“浮动垃圾”。也是由于在垃圾收集阶段用户线程还需要运行,那也就还需要预留有足够的内存空间给用户线程使用,因此CMS收集器不能像其他收集器那样等到老年代几乎完全被填满了再进行收集,需要预留一部分空间提供并发收集时的程序运作使用。
  • 标记-清除算法导致的空间碎片: CMS是一款基于“标记-清除”算法实现的收集器,这意味着收集结束时会有大量空间碎片产生。空间碎片过多时,将会给大对象分配带来很大麻烦,往往出现老年代空间剩余,但无法找到足够大连续空间来分配当前对象。

G1垃圾收集器:

文章: 美团技术团队

是一款面向服务端应用的基于标记-整理算法的垃圾收集器. 可预测的停顿 功能会建立可预测的停顿时间模型,能让使用者明确指定在一个长度为M毫秒的时间片段内,消耗在GC上的时间不得超过N毫秒,这几乎已经是实时Java(RTSJ)的垃圾收集器的特征了

横跨整个堆内存: G1在使用时,Java堆的内存布局与其他收集器有很大区别,它将整个Java堆划分为多个大小相等的独立区域(Region),虽然还保留新生代和老年代的概念,但新生代和老年代不再是物理隔离的了,而都是一部分Region(不需要连续)的集合

建立可预测的时间模型: G1收集器之所以能建立可预测的停顿时间模型,是因为它可以有计划地避免在整个Java堆中进行全区域的垃圾收集。G1跟踪各个Region里面的垃圾堆积的价值大小(回收所获得的空间大小以及回收所需时间的经验值),在后台维护一个优先列表,每次根据允许的收集时间,优先回收价值最大的Region(这也就是Garbage-First名称的来由)。这种使用Region划分内存空间以及有优先级的区域回收方式,保证了G1收集器在有限的时间内可以获取尽可能高的收集效率.

避免全堆扫描——Remembered Set: G1把Java堆分为多个Region,就是"化整为零"。但是Region不可能是孤立的,一个对象分配在某个Region中,可以与整个Java堆任意的对象发生引用关系。在做可达性分析确定对象是否存活的时候,需要扫描整个Java堆才能保证准确性,这显然是对GC效率的极大伤害。     为了避免全堆扫描的发生,虚拟机为G1中每个Region维护了一个与之对应的Remembered Set。虚拟机发现程序在对Reference类型的数据进行写操作时,会产生一个Write Barrier暂时中断写操作,检查Reference引用的对象是否处于不同的Region之中(在分代的例子中就是检查是否老年代中的对象引用了新生代中的对象)。如果是,便通过CardTable把相关引用信息记录到被引用对象所属的Region的Remembered Set之中。当进行内存回收时,在GC根节点的枚举范围中加入Remembered Set即可保证不对全堆扫描也不会有遗漏。

G1提供了两种GC模式,Young GC和Mixed GC,两种都是完全Stop The World的。

  • Young GC:选定所有年轻代里的Region。通过控制年轻代的region个数,即年轻代内存大小,来控制young GC的时间开销。
  • Mixed GC:选定所有年轻代里的Region,外加根据global concurrent marking统计得出收集收益高的若干老年代Region。在用户指定的开销目标范围内尽可能选择收益高的老年代Region。

由上面的描述可知,Mixed GC不是full GC,它只能回收部分老年代的Region,如果mixed GC实在无法跟上程序分配内存的速度,导致老年代填满无法继续进行Mixed GC,就会使用serial old GC(full GC)来收集整个GC heap。

如果不计算维护Remembered Set的操作,G1收集器的运作大致可划分为以下几个步骤:

  • 初始标记(Initial Marking): 仅仅只是标记一下GC Roots 能直接关联到的对象,并且修改TAMS(Nest Top Mark Start)的值,让下一阶段用户程序并发运行时,能在正确可以的Region中创建对象,此阶段需要停顿线程,但耗时很短。
  • 并发标记(Concurrent Marking): 从GC Root 开始对堆中对象进行可达性分析,找到存活对象,此阶段耗时较长,但可与用户程序并发执行
  • 最终标记(Final Marking): 为了修正在并发标记期间因用户程序继续运作而导致标记产生变动的那一部分标记记录,虚拟机将这段时间对象变化记录在线程的Remembered Set Logs里面,最终标记阶段需要把Remembered Set Logs的数据合并到Remembered Set中,这阶段需要停顿线程,但是可并行执行
  • 筛选回收(Live Data Counting and Evacuation): 首先对各个Region中的回收价值和成本进行排序,根据用户所期望的GC 停顿是时间来制定回收计划。此阶段其实也可以做到与用户程序一起并发执行,但是因为只回收一部分Region,时间是用户可控制的,而且停顿用户线程将大幅度提高收集效率。

0bce1667.png

8ca16868.png

PS:

  • H是老年代的大对象
  • Remembered Set解决了CMS必须全内存扫描才能判断对象不可达的问题

CMS和G1的区别:

  • CMS :并发标记清除。他的主要步骤有:初始收集,并发标记,重新标记,并发清除(删除)、重置。
  • G1:主要步骤:初始标记,并发标记,重新标记,复制清除(整理)
  • CMS 的缺点是对 CPU 的要求比较高。G1是将内存化成了多块,所有对内段的大小有很大的要求。
  • CMS是清除,所以会存在很多的内存碎片。G1是整理,所以碎片空间较小。

ZGC垃圾回收算法:

设计目标:

  • 停顿时间不超过10ms
  • 停顿时间不会随着堆的大小,或者活跃对象的大小而增加
  • 支持8MB~4TB级别的堆(未来支持16TB)

ZGC也采用标记-复制算法,不过ZGC对该算法做了重大改进: ZGC在标记、转移和重定位阶段几乎都是并发的,这是ZGC实现停顿时间小于10ms目标的最关键原因

文章: 美团技术团队

JDK各版本默认垃圾收集器:

  • JDK5~JDK8: Parallel Scavenge(新生代)+Parallel Old(老年代)
  • JDK9~JDK16: G1

真实JVM问题分析:

文章: 线上处理CPU过高

分析问题时的命令:

  • dmesg(display message): 查看linux开机之后的系统日志,其中可以捕捉到一些系统资源与进程的变化信息. dmesg |grep -E 'kill|oom|out of memory'查看内存溢出
  • ps: 显示当前进程的状态,类似windows任务管理器. 常用的有ps efps aux,它两主要是显示的内容格式不同.
    • ef: 用标准的格式显示进程: 用户ID、进程ID、父进程ID、进程占用CPU的百分比、进程启动到现在的时间、TTY(略)、命令的名称和参数
    • aux: 用BSD的格式来显示: 用户名、进程占用的CPU百分比、占用内存的百分比、该进程使用的虚拟內存量(KB)、该进程占用的固定內存量(KB)(驻留中页的数量)、进程的状态、该进程被触发启动时间、该进程实际使用CPU运行的时间
  • jstat: 查看当前GC的状态. jstat -gcutil ${pid} ${interval}
  • jmap: 查看对象分布情况. jmap -histo:live ${pid}查看存活对象分布情况. jmap -dump:format=b,file=${fileName} ${pid}导出成文件
  • gcore: 保存linux内存信息(比jmap快). 导出JVM对象步骤: 1. sudo gdb -q --pid ${pid} 2. (gdb) generate-core-file # 这里调用命令生成gcore的dump文件 3. (gdb) gcore /tmp/jvm.core # dump出core文件 4. (gdb) detach # detach是用来断开与jvm的连接的 5. (gdb) quit # 退出
    6. jmap -dump:format=b,file=heap.hprof /opt/daxigua/java/bin /tmp/jvm.core # core文件转换成hprof
  • jstack: 打印线程的堆栈信息. jstack ${tid} |grep xxx -A ${after-number-lines}看某个线程堆栈的整体信息

CPU占用过高排查流程:

  1. ps –ef | grep ${process}: 找进程ID (或者前面再加一步: TOP + P)
  2. top -H -p ${pid}: 看执行进程的进程信息
  3. ps -mp pid -o THREAD,tid,time: 显示特定进程下全部线程列表使用情况,以定位到是哪个线程占用CPU时间最高
  4. printf "%x\n" ${tid}: 将认为有问题的线程ID转码16进制(为jstack做准备)
  5. jstack ${pid} |grep ${hex_pid} -A 30: 看这个有问题的线程的堆栈信息(向后30行)

ClassLoader

类加载器是如何加载 Class 文件的:

08.png

  • 第一个阶段,加载(Loading),是找到 .class 文件并把这个文件包含的字节码加载到内存中。
  • 第二阶段,连接(Linking),又可以分为三个步骤,分别是字节码验证、Class类数据结构分析及相应的内存分配、最后的符号表的解析。
  • 第三阶段,初始化(类中静态属性和初始化赋值),以及静态块的执行等
  1. 加载

在这个阶段,虚拟机需要完成以下三件事情:

  • 通过一个类的全限定名来获取其定义的二进制字节流。
  • 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。
  • 在Java堆中生成一个代表这个类的 java.lang.Class 对象,作为对方法区中这些数据的访问入口。

相对于类加载的其他阶段而言,加载阶段(准确地说,是加载阶段获取类的二进制字节流的动作)是可控性最强的阶段,因为开发人员既可以使用系统提供的类加载器来完成加载,也可以自定义自己的类加载器来完成加载。

加载阶段完成后,虚拟机外部的二进制字节流就按照虚拟机所需的格式存储在方法区之中,而且在Java堆中也创建一个 java.lang.Class 类的对象,这样便可以通过该对象访问方法区中的这些数据。

  1. 连接

2.1 验证:确保被加载的类的正确性. 验证阶段大致会完成4个阶段的检验动作:

  • 文件格式验证:验证字节流是否符合 Class 文件格式的规范。例如:是否以 0xCAFEBABE 开头、主次版本号是否在当前虚拟机的处理范围之内、常量池中的常量是否有不被支持的类型。
  • 元数据验证:对字节码描述的信息进行语义分析(注意:对比 javac 编译阶段的语义分析),以保证其描述的信息符合 Java 语言规范的要求。例如:这个类是否有父类,除了 java.lang.Object 之外。
  • 字节码验证:通过数据流和控制流分析,确定程序语义是合法的、符合逻辑的。
  • 符号引用验证:确保解析动作能正确执行。

2.2 准备:为类的静态变量分配内存,并将其初始化为默认值. 准备阶段,是正式为类变量分配内存并设置类变量初始值的阶段,这些内存都将在方法区中分配。对于该阶段有以下几点需要注意:

  • 这时候进行内存分配的仅包括类变量(static),而不包括实例变量,实例变量会在对象实例化时随着对象一块分配在 Java 堆中。
  • 这里所设置的初始值通常情况下是数据类型默认的零值(如 00Lnullfalse 等),而不是被在 Java 代码中被显式地赋予的值。
  • 如果类字段的字段属性表中存在 ConstantValue 属性,即同时被 finalstatic 修饰,那么在准备阶段变量 value 就会被初始化为 ConstValue 属性所指定的值

2.3 解析:把类中的符号引用转换为直接引用. 主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用点限定符 7 类符号引用.

  • 符号引用,就是一组符号来描述目标,可以是任何字面量。
  • 直接引用,就是直接指向目标的指针、相对偏移量或一个间接定位到目标的句柄。

PS: 符号引用就是字符串,这个字符串包含足够的信息,以供实际使用时可以找到相应的位置。比如说某个方法的符号引用,如:“java/io/PrintStream.println:(Ljava/lang/String;)V”。当第一次运行时,要根据字符串的内容,到该类的方法表中搜索这个方法(深度优先遍历)。运行一次之后,符号引用会被替换为直接引用,下次就不用搜索了。直接引用就是偏移量,通过偏移量虚拟机可以直接在该类的内存区域中找到方法字节码的起始位置.

文章: JVM符号引用和直接引用

  1. 初始化

初始化,为类的静态变量赋予正确的初始值,JVM 负责对类进行初始化,主要对类变量进行初始化。

JVM 初始化步骤如下:

  1. 假如这个类还没有被加载和连接,则程序先加载并连接该类。
  2. 假如该类的直接父类还没有被初始化,则先初始化其直接父类。
  3. 假如类中有初始化语句,则系统依次执行这些初始化语句。

JDK自带的ClassLoader:

  • Bootstrap ClassLoader: 加载存放在JDK\jre\lib下(比如: java.*开头的类),或被-Xbootclasspath参数指定的路径中的。
  • Extension ClassLoader: 加载JDK\jre\lib\ext下,或者由java.ext.dirs系统变量指定的路径中的所有类库(如javax.*开头的类)
  • System ClassLoader: 加载用户类路径(ClassPath)所指定的类,一般情况下这个就是程序中默认的类加载器

自定义ClassLoader可以做一下几点:

  1. 在执行非置信代码之前,自动验证数字签名。
  2. 动态地创建符合用户特定需要的定制化构建类。
  3. 从特定的场所取得java class,例如数据库中和网络中。
  4. 自定义类加载机制。 PS: Tomcat的WebappClassLoader.

类加载机制(双亲委派模型):

工作流程:

  1. 当前 ClassLoader 首先从自己已经加载的类中,查询是否此类已经加载,如果已经加载则直接返回原来已经加载的类。
  2. 当前 ClassLoader 的缓存中没有找到被加载的类的时候: 2.1: 委托父类加载器去加载,父类加载器采用同样的策略,首先查看自己的缓存,然后委托父类的父类去加载,一直到 bootstrap ClassLoader。 2.2: 当所有的父类加载器都没有加载的时候,再由当前的类加载器加载,并将其放入它自己的缓存中,以便下次有加载请求的时候直接返回。

这有一个很明显的好处,父类加载器已经加载过的类,不用再次加载,而且对于一些系统类,用户自定义的不起作用了,有一定安全保证。

Tomcat的WebappClassLoader:

文章: Tomcat WebappClassLoader

它有三个ClassLoader: CommonLoader、CatalinaLoader、SharedLoader,遵循双亲委派机制.

  • CommonLoader是父类加载器,它会加载Web应用程序和Tomcat共享的类.
  • CatalinaClassloader: 它会加载conf/catalina.properties配置文件里的tomcat自身的jar
  • SharedLoader类加载器作为参数调用了Catalina的setParentClassLoader方法,成为了整个Catalina容器的父类加载器,当然也是WebAppClassLoader的父类加载器.
  • WebAppClassLoader类加载器代表了我们的每个Tomcat应用(一个Tomcat可以放多个应用),它优先加载当前应用目录下(/WEB-INF/classes/WEB-INF/lib)的类(当然,还是会去加载JVM里的),原因是每个APP的类要隔离, 所以它打破了双亲委派模型

RocketMQ

延迟消息实现:

延迟参数大于0则将消息以PROPERTY_REAL_TOPIC为Topic,延迟等级为queueId持久化到commitlog中,并通过ScheduleMessageService定时任务轮询,然后还原消息到commitlog中供真正的消费者消费.

事务消息实现:

文章: RocketMQ事务消息设计(官方)

PS: 其实就是一个2PC,保证一定会跟MQ有关于这个事务的结果.

事务消息发送及提交:

  1. Producer发送消息(half消息).
  2. MQ响应消息写入结果.
  3. 服务端根据发送结果执行本地事务(如果写入失败,此时half消息对业务不可见,本地逻辑不执行).
  4. Producer根据本地事务状态执行Commit或者Rollback(Commit操作生成消息索引,消息对消费者可见)

补偿流程:

  1. 对没有Commit/Rollback的事务消息(pending状态的消息),从服务端发起一次"回查"
  2. Producer收到回查消息,检查回查消息对应的本地事务的状态
  3. 根据本地事务状态,重新Commit或者Rollback

其中,补偿阶段用于解决消息Commit或者Rollback发生超时或者失败的情况.

rocketmq_design_10.png

值得注意的是,rocketmq并不会无休止的的信息事务状态回查,默认回查15次,如果15次回查还是无法得知事务状态,rocketmq默认回滚该消息

事务消息在一阶段如何对用户不可见: 如果消息是half消息,将备份原消息的主题与消息消费队列,然后改变主题为RMQ_SYS_TRANS_HALF_TOPIC. 然后RocketMQ会开启一个定时任务,从Topic为RMQ_SYS_TRANS_HALF_TOPIC中拉取消息进行消费,根据生产者组获取一个服务提供者发送回查事务状态请求,根据事务状态来决定是提交或回滚消息.

消息丢失:

三种情况:

  1. 生产者发送失败(消息抖动)
  2. RocketMQ自身原因(写盘)
  3. 消费者逻辑处理不正确(没成功处理却先返回消费成功)

处理方案:

  1. 生产者问题: 生产者用事务消息机制.
  2. RocketMQ自身原因: 2.1: 存盘机制改为同步存盘 2.2: 配主从架构.

Linux

TOP命令:

文章: TOP命令详解

常用(这些都是进入TOP后的命令,不是参数): P: 以CPU的使用资源排序显示 M: 以内存的使用资源排序显示 N: 以pid排序显示 T: 由进程使用的时间累计排序显示 k:给某一个pid一个信号,可以用来杀死进程 r:给某个pid重新定制一个nice值(优先级)

显示指定进程信息: top -H -p ${pid}

lsof(list open files)命令:

文章: lsof命令

可以用来筛某个用户、某中协议、某个进程打开的文件


Netty

BIO(Block-IO): 是一种阻塞 + 同步的通信模式

NIO(New IO,也叫 Non-Block IO): 是一种非阻塞 + 同步的通信模式. 由之前的一个连接一个线程变成了一个请求一个线程

AIO: 是一种阻塞 + 异步的通信模式. 服务器实现模式为一个有效请求一个线程,客户端的I/O请求都是由OS先完成了再通知服务器应用去启动线程进行处理,适合连接数较多且连接时间较长的应用

同步: Java自己处理IO读写;

异步: Java将IO读写委托给OS处理,需要将数据缓冲区地址和大小传给OS,OS需要支持异步IO操作API;

阻塞: Java调用会一直阻塞到读写完成才返回;

非阻塞:如果不能读写Java调用会马上返回,当IO事件分发器会通知可读写时再继续进行读写,不断循环直到读写完成

Netty处理NIO空转BUG:

文章: NIO的空轮询bug

解决方法就是在while轮询时给空转次数计数,一定时间内达到指定空转次数就直接重建Selector

Netty处理粘包:

文章: Netty解决粘包和拆包问题的四种方案

四种方法:

  1. 固定长度(FixedLengthFrameDecoder)
  2. 特殊符号标识(LineBasedFrameDecoder与DelimiterBasedFrameDecoder)
  3. 显示在消息中指定长度(LengthFieldBasedFrameDecoder与LengthFieldPrepender)
  4. 自定义粘包与拆包器

线程状态以及对应操作:

线程状态机.png

  • 新建(new):当创建Thread类的一个实例(对象)时,此线程进入新建状态(未被启动)。例如:Thread t1 = new Thread()
  • 可运行(runnable):线程对象创建后,其他线程(比如 main 线程)调用了该对象的 start 方法。该状态的线程位于可运行线程池中,等待被线程调度选中,获取 cpu 的使用权。例如:t1.start()
  • 运行(running):线程获得 CPU 资源正在执行任务(#run() 方法),此时除非此线程自动放弃 CPU 资源或者有优先级更高的线程进入,线程将一直运行到结束。
  • 死亡(dead):当线程执行完毕或被其它线程杀死,线程就进入死亡状态,这时线程不可能再进入就绪状态等待执行。
    • 自然终止:正常运行完 #run()方法,终止。
    • 异常终止:调用 #stop() 方法,让一个线程终止运行。
  • 堵塞(blocked):由于某种原因导致正在运行的线程让出 CPU 并暂停自己的执行,即进入堵塞状态。直到线程进入可运行(runnable)状态,才有机会再次获得 CPU 资源,转到运行(running)状态。阻塞的情况有三种:
    • 正在睡眠:调用 #sleep(long t) 方法,可使线程进入睡眠方式。

      一个睡眠着的线程在指定的时间过去可进入可运行(runnable)状态。

    • 正在等待:调用 #wait() 方法。

      调用 notify() 方法,回到就绪状态。

    • 被另一个线程所阻塞:调用 #suspend() 方法。

      调用 #resume() 方法,就可以恢复。


请求close_wait、time_wait等问题:

文章: TCP连接的TIME_WAIT和CLOSE_WAIT 状态解说

netstat -n | awk '/^tcp/ {++S[$NF]} END {for(a in S) print a, S[a]}'会返回几个状态: TIME_WAIT、CLOSE_WAIT、FIN_WAIT1、ESTABLISHED、SYN_RECV、LAST_ACK. 常用的三个状态是:

  • ESTABLISHED: 表示正在通信.
  • TIME_WAIT: 表示主动关闭.
  • CLOSE_WAIT: 表示被动关闭.

如果服务器出了异常,百分之八九十都是:

  1. 服务器保持了大量TIME_WAIT状态;
  2. 服务器保持了大量CLOSE_WAIT状态;

linux分配给一个用户的文件句柄是有限的,而TIME_WAIT和CLOSE_WAIT两种状态如果一直被保持,那么意味着对应数目的通道就一直被占着,一旦达到句柄数上限, 新的请求就无法被处理了.

TIME_WAIT是主动关闭连接的一方保持的状态,客户端完成一个请求任务之后,就会发起主动关闭连接,从而进入TIME_WAIT的状态,然后在保持这个状态2MSL(max segment lifetime)时间之后,彻底关闭回收资源.

  1. 防止上一次连接中的包,迷路后重新出现,影响新连接(经过2MSL,上一次连接中所有的重复包都会消失)
  2. 可靠的关闭TCP连接.在主动关闭方发送的最后一个 ack(fin) ,有可能丢失,这时被动方会重新发fin, 如果这时主动方处于CLOSED状态,就会响应rst而不是ack.所以主动方要处于TIME_WAIT状态,而不能是CLOSED.另外这么设计TIME_WAIT会定时的回收资源,并不会占用很大资源的,除非短时间内接受大量请求或者受到攻击.

TIME_WAIT状态可以通过优化服务器参数得到解决,因为发生TIME_WAIT的情况是服务器自己可控的: 要么就是对方连接的异常;要么就是自己没有迅速回收资源. 总之不是由于自己程序错误导致的。

四次握手关闭连接:

  1. 主动关闭连接的一方,调用close();协议层发送FIN包
  2. 被动关闭的一方收到FIN包后,协议层回复ACK;然后被动关闭的一方,进入CLOSE_WAIT状态,主动关闭的一方等待对方关闭,则进入FIN_WAIT_2状态;此时,主动关闭的一方等待被动关闭一方的应用程序,调用close操作
  3. 被动关闭的一方在完成所有数据发送后,调用close()操作;此时,协议层发送FIN包给主动关闭的一方,等待对方的ACK,被动关闭的一方进入LAST_ACK状态
  4. 主动关闭的一方收到FIN包,协议层回复ACK.此时,主动关闭连接的一方进入TIME_WAIT状态,而被动关闭的一方,进入CLOSED状态
  5. 等待2MSL时间,主动关闭的一方,结束TIME_WAIT,进入CLOSED状态

通过上面的一次socket关闭操作,可以得出以下几点:

  1. 主动关闭连接的一方,也就是主动调用socket的close操作的一方,最终会进入TIME_WAIT状态.
  2. 被动关闭连接的一方,有一个中间状态,即CLOSE_WAIT,因为协议层在等待上层的应用程序,主动调用close操作后才主动关闭这条连接.
  3. TIME_WAIT会默认等待2MSL时间后,才最终进入CLOSED状态.
  4. 在一个连接没有进入CLOSED状态之前,这个连接是不能被重用的.

所以说这里凭直觉看,TIME_WAIT并不可怕,CLOSE_WAIT才可怕,因为CLOSE_WAIT很多,表示说要么是你的应用程序写的有问题,没有合适的关闭socket;要么是说,你的服务器CPU处理不过来(CPU太忙)或者你的应用程序一直睡眠到其它地方(锁,或者文件I/O等等),你的应用程序获得不到合适的调度时间,造成你的程序没法真正的执行close操作。


Elasticsearch

Elasticsearch长尾问题:

文章: Elasticsearch千万级TPS写入

每批次BulkRequest指定同一个Routing,这样就不需要一次写入需要等所有Shards都成功才返回,因为只会写入一个节点

Replica同步数据机制:

es没有主备副本的概念, 数据导入场景shard和replica是同时写入的,都写入成功,这一次写入才完成

其它:

  • 大量数据同步的时候可以先关掉refresh_intervalnumber_of_replicas,同步完再开启

判断链表是否有环:

slow、fast两点,slow每次一步,fast每次两步,fast走到null时表示没有环,碰到了slow表示有环.

微服务:

异构、云上扩展消耗、企业组织架构(去中心化)、快速迭代、功能共享、细粒度监控、边界清晰

限流常见处理方法:

  • 限制总并发数(数据库连接池、线程池等)
  • 限制瞬时并发数(如nginx的limit_conn模块m用来限制瞬时并发连接数)
  • 限制时间窗内的平均速率(如Guava的RateLimiter、nginx的limit_req模块,限制每秒的平均速率)
  • 其他还有如限制远程接口调用速率、限制MQ的消费速率. 另外还可以根据网络连接数、网络流量、CPU或内存负载等来限流.

Object.wait()和Thread.sleep()的区别:

文章: 为什么wait,notify,notifyAll方法定义在Object而不是Thread类中

两者都可以让线程暂停一段时间,但本质的区别是: 一个是线程之间的通讯;一个是线程的运行状态控制的问题.

  1. wait()释放锁,sleep()不释放

FLP:

FLP不可能原理告诉我们,不要浪费时间去试图为异步分布式系统设计面向任意场景的共识算法

String#intern()方法:

方法注释: 如果常量池中存在当前字符串, 就会直接返回当前字符串. 如果常量池中没有此字符串, 会将此字符串放入常量池中后, 再返回

8种基本类型的常量池都是系统协调的,String类型的常量池比较特殊。它的主要使用方法有两种:

  • 直接使用双引号声明出来的String对象会直接存储在常量池中。
  • 如果不是用双引号声明的String对象,可以使用String提供的intern方法。intern 方法会从字符串常量池中查询当前字符串是否存在,若不存在就会将当前字符串放入常量池中

ConfigurableApplicationContext#setParent应用场景:

  1. 用于配置多个DispatcherServlet,父容器来扫描各种数据源、Service等,子容器(DispatcherServlet)来扫自己的Controller并作权限控制(JSPGOU是这么搞的).
  2. 拿Bean的时候会从parent开始拿. 发事件的时候也会发一份给parent.

Redis Sorted Set的底层结构:

ziplistskiplist.

只有同时满足如下条件是,使用的是ziplist,其他时候则是使用skiplist:

  • 有序集合保存的元素数量小于128个
  • 有序集合保存的所有元素的长度小于64字节

Eureka健康检测机制:

文章: Spring Cloud Eureka自我保护机制

八大计数数据类型从小到大:

byte、boolean、short、char、int、float、double、long

MySQL的utf8和UTF-8的区别:

UTF-8是变长编码,一个符号使用1~4个字节表示; MySQL的utf8使用1到3个字节表示.