小小复习

趁这时间把过去所学过的东西全都过一次……

Java 本身

集合类型

ArrayList,Map,HashMap,LinkedHashMap,IdentityHashMap,HashSet,IdentityHashSet。

LinkedHashMap 记录插入顺序,可以直接拿它实现 LRU 缓存——我删旧的然后重新插入。

IdentityHashMap 先 hashcode 再 ==,HashMap 先 hashcode 再 equal

JVM 内存区域

总之,每个线程都有自己的函数调用栈(具体分为虚拟机栈和本地方法栈)和程序计数器,而所有线程都能访问堆和方法区和直接内存。

方法区存储常量啊编译后的代码啊(比如各种类信息)。但我们实际编程的时候一般就考虑两部分——堆和栈,一般来说本地的基础类型的变量存储在栈上,引用类型就只存储指针到栈上然后实际存在堆上。

直接内存则只有使用 NIO 编程的时候才会接触到,它直接分配堆外内存以保证高效之类的,但我没有系统学习。

GC

JVM 采用的不是引用计数而是可达性分析,去判断不再使用的对象。

垃圾回收机制其实就是回收堆中不再使用的对象。JVM 的 GC 机制将堆分为两部分——新生代和老年代,新生代包括亚当区和两个 Survivor 区,对象一般首先分配在 Eden 区,一般来说多次垃圾回收后仍没被清除的对象的年龄计数会增加,最后进入老年代。GC 分为两种,Minor GC 和 Full GC,前者只回收新生代。

新生代包括 Eden(亚当,意义不言而喻)区和两个 Survivor 区(称为 s0,s1,或 from,to 区)。对象一般首先在 Eden 区分配,在经过一次新生代垃圾回收后,如果对象还存活,则进入 s0,s1,年龄加 1(或初始年龄设为 1),年龄增长到一定程度则晋升到老年代。

ClassLoader

双亲委派——其实该叫父类委派,即一个类优先递归地由自己的父加载器去加载,只有在父加载器无法处理地时候才自己加载。这保证用户无法篡改 Java 核心类等以保证安全,而用户仍旧有自己手动加载什么东西的能力。

并发编程

并发编程其实就是关于如何处理临界区资源的问题,在业务代码中就是尽量保证访问的原子性,避免并发写等,手段包括使用 ThreadLocal 或者使用 CopyOnWrite 或者 Atomic 的类型,加乐观锁或悲观锁等,当然在有的情况下还得上分布式锁。

然后并发访问的可变变量一般来说需要加 volatile 保证可见性,而不可变变量比如 Atomic 的或者 ConcurrentHashMap 就不需要加。实践上一般双重检查锁或者布尔的标志变量需要加。

线程池

线程池按任务分可以分为三个区域——核心线程,非核心线程,队列。核心线程是线程池中持久存活的线程,任务优先安排到核心线程,核心线程都在执行任务时则丢到队列里,队列满则创建非核心线程(注意是先队列再非核心线程,因为操作队列比创建线程轻量多了)。

线程池的配置项:

  • 核心线程池大小 corePoolSize,总是至少有这些线程存活
  • 最大线程池大小 maximumPoolSize,队列满后会创建新线程,但线程总数不会超过这个数
  • 非核心线程空闲存活时间和这个时间的单位 keepAliveTime,unit,超出这个时间还没被使用过的非核心线程会被销毁
  • 工作队列 workQueue (一个阻塞队列,可以联想到消费者-生产者并发模式)
  • 线程的工厂 threadFactory,这个主要是用于修改线程的名称或者对线程本身做一些操作,比如不使用原生的线程而是
  • 拒绝策略 handler,队列满且线程池满时执行,默认行为是直接在调用者处抛出异常

工作队列可以说可以分为 3 类:无界(LinkedBlockingQueue 默认就是无界),有界,空队列(SynchronousQueue),空队列即容量为 0,它用于直接的“移交”,放入队列的操作必须要等待移出队列的操作,反之亦然。

拒绝策略则包括在调用者处抛异常,直接丢弃,让调用者执行,或丢弃队列中最老的任务。

Executors 包的问题

Executors 的 4 个默认线程池都有问题:

  • newFixedThreadPool:固定线程池大小(核心线程数等于最大线程数),无界队列
    • 因为处理的线程数无法增加,所以如果任务提供太快会导致队列太长导致 OOM
  • newSingleThreadExecutor:单线程,无界队列
    • 同上,
  • newCachedThreadPool:核心线程数为 0,队列大小也为 0(SyncgronousQueue)
    • 会导致创建大量线程导致 OOM
  • newScheduledThreadPool:定时、周期任务(这个和上面的仨就不是同一套了)

设计模式

  • 迭代器模式:关于如何迭代一个容器,这 tm 已经是一般行为了,还用说?
  • 适配器模式:不是直接使用一个第三方类,而是使用一个(标准的)Adapter 接口去实现适配。适配器重在使用同一套接口适应不同的实现,其实这更多说是一种思想(这个设计模式的形式有点搞笑说实话)。就比如 OpenClaw 对各个 Channel 的抽象,那肯定是使用同一个接口去实现各个不同的“后端”,这也是一种适配器
  • 模板方法模式:父类调用子类的实现,这个其实也非常 trivial,但细说回来这其实也是一种控制反转。但要我说,能用模板方法的地方很多时候大概也可以用策略模式,父类直接持有子类的实现
  • 单例模式:饿汉,懒汉,双重检查锁
  • 工厂模式和抽象工厂模式:使用 new 时,客户端负责类的创建和使用,而工厂将客户端从类的创建中解放,只负责使用。
    • 具体的我 pass,现在在业务中都没机会用这个了
  • 原型模式:克隆(和基于原型的 OOP 不是一回事)
  • 建造者模式:没啥好说的
  • 策略模式:基于委托的,可替换的业务逻辑
  • 命令模式:作为数据的可以到处传的命令
  • 状态模式:将状态内化到类本身,通过修改自己来切换自己的逻辑
  • 访问者模式:扩展方法,也可做模式匹配

其他的我都没学,我记得有享元,桥接,门面,导演等。

数据库

CRUD

SQL 语句执行顺序——FROM(表),WHERE(筛选),GROUP BY(分组),HAVING(组筛选),SELECT(字段选择和聚集),WINDOW(窗口函数(也是对各组去做窗口操作)),ORDER BY(排序)。

SELECT 在分组后执行,在 WHERE 后执行,因此 WHERE 中不能利用 SELECT 的字段,因此 SELECT 可以有聚集函数,因此 WHERE 中不能有聚集函数,

  • SELECT: SELECT ... FROM tb WHERE ... GROUP BY ... HAVING ... ORDER BY ... LIMIT ...
  • UPDATE: UPDATE tb SET ... WHERE ...
  • DELETE: DELETE FROM tb WHERE ...
  • INSERT: INSERT INTO tb(...) VALUES ...

联表查询

子查询和关联子查询,IN,EXISTS,JOIN。

在大多数时候用 EXISTS 而非 IN 性能更好,这个还挺显著。

我个人不习惯在无必要的情况下使用 JOIN。

调优(索引)

索引独立于主表维护,提供根据特定字段的快速的访问。

分库分表

曾经使用过读写分离和按时间水平分表

Spring,Ruoyi,中间件

IOC

IoC,即我不用关心我所需要的对象的创建和依赖管理,控制权交给框架,我自己只需要关心使用。而 Spring 实现此是使用的依赖注入。

AOP

AOP 对于有接口的类使用 JDK 提供的代理模式实现,否则使用 CGLIB 创建动态代理因为这时候没法很方便地使用代理模式了。CGLIB 这时候生成目标类的子类

Spring Boot 自动配置

使用@EnableAutoConfiguration 注解后,Spring Boot 会扫描 META-INF/spring.factories,这是一个 properties 格式的文件,Spring Boot 自动地将其中的 AutoConfiguration 类进行实例化,然后再根据它的 Conditional 注解去实例化它声明的类。

事务

一般走注解式的事务管理。

Spring 事务的传播有多种——沿用当前事务或新建事务,嵌套事务等。嵌套事务报错允许只回滚嵌套事务本身(但不管这个异常的话当然会扩散给父事务),它的提交则是和父事务一起提交。

Redis

常用数据类型:

  • String 字符串和数字(key->value)
  • Hash 键值对集合(key->field->value)
  • List 双向链表
  • Set 无序集合
  • HyperLogLog PVUV 计数

Redis 分布式锁

原生锁

分布式锁一般来说使用的是使用 setnx 方法,set if not exists,如果设置成功就证明持有了锁。然后为了避免死锁会再设置一个超时时间。这里获取锁设设置超时时间如果使用 setnx 命令则需要两个命令,非原子,为了保证原子性用新版的 redis 的 set 命令,执行 nx 的同时也设置超时时间 SET lock_key unique_id NX EX 30 这样。

同时设置锁需要保证客户端是自己,一般使用一个唯一标识符去标识特定客户端;解锁时需要 get 检查锁是否是自己的,再 del,这里需要使用 lua 脚本保证原子性。

然后这么写是不可重入锁。

获取锁时,可以使用简单的自旋,也可以依赖 Redis 的发布订阅机制配合 CountDownLatch(或者 SynchronousQueue)去保证及时性和性能。

redlock

简单来说就是向 N 个 Redis 实例同时加锁,加锁总耗时时间要小于锁过期时间,同时至少N/2+1个实例加锁成功,否则认为加锁失败,发送解锁请求。

但 redlock 一般是没必要的。redisson 提供了对 redlock 的抽象,但我没用过。

Nacos 服务发现和配置中心

NACOS 提供服务发现和配置中心功能,

RuoYi Cloud

RuoYi Cloud 架构——gateway,auth 依赖 system,业务 module。gateway 就是 spring gateway,大家都依赖 nacos 的配置中心和服务发现。

docker,K8S,监控

k8s 概念

k8s 是集群编排应用,提供高可用,不停机上线,横向拓展,负载均衡,容器调度,健康检查,错误恢复等功能。k8s 是容器化的,和 docker 联系紧密。

k8s 中的物理机称为 Node,节点分为 Control Plane 和 Node,一般而言前者负责管理,后者负责运行容器。

每个 node 都有 Kubelet 负责管理 node 并和 k8s 集群交互。

Pod 是 k8s 的原子,调度的最小单位,Pod 类似 Docker 容器,但一个 Pod 可以包含多个 Docker 容器并共享同样的网络和存储资源。Pod 可以有状态,一个 Pod 可以包含同一个容器的多个实例。应用的横向扩容以 Pod 为原子。

Deployment 和 StatefulSet 类似 Docker compose 的 service,无状态的应用用前者,有状态的如 nacos,mysql 用后者。

Service 则用于暴露网络服务,它为各个 Pod 提供 DNS(ClusterIP),方便做负载均衡等。然后还有 NodePort(直接暴露在 Node 上供外部访问)。

configMap 就是 k8s 中的环境变量,用于替代 bootstrap.yml

prometheus

普罗米修斯用于监控,我学过它的表达式语言但也就仅此而已。

算法

PASS,牛客各个类型的题都刷了一些,找回节奏来了。

项目经历

大连华信

PVUV

前端通用埋点,后端使用 hyperloglog 计数并定时落库。

数据同步

当时研究了多种方案,binlog,第三方的 mysql 的数据同步框架……最终决定是自己使用消息队列去实现了,大部分地方使用的是注解,少部分地方被迫使用编程式。但这一套东西其实到最后也没用上去,没有搞幂等性也没有搞啥的。

区综治

人口内存表

后端是 oracle,人口数据中有 80 万条数据且经常有非常复杂的链表查询走不了索引,因此使用 mysql 整了内存表,做一个宽表去囊括所有查询的字段,同步机制是每次启动时全量同步,然后有写操作时写一个日志表,内存表高频监听这个日志表并做增量同步。

结果后面的一般查询的形式是先从内存表中查人口 ID 再从 oracle 中根据 ID 去查询,这引入了页内的排序问题,然后分页数据没带过去的问题,业内排序通过在 Java 内排序解决,当时我做了一个特殊的 Comparator 抽象去根据一个顺序去进行排序并将这个模式给记成笔记,然后分页信息就直接拷贝 PageInfo 的信息。

国密

国密算法的抽象不是我做的。国密最头疼的是对代码侵入性的问题,我自己的话会倾向直接在 mysql 或者在哪里做处理,但当时项目经理权衡利弊后直接在代码中硬编码了,因为似乎只能这样因为加密的字段有大量联表操作,为此测了好久,还出现过好几次重复加密解密的问题。

接口性能优化,性能差的接口大都是做了无必要的联表查询,这个也没有总结出明确的方法论,见仁见智。

柳州智慧派出所

这个没啥好说的,当时可以说只是一个技术验证。

天网

微服务架构迁移

这个微服务迁移的代码迁移都是我做的,其实并非是必要的,迁到 kubernates 搞云原生是政治任务所以做的很草,业务只分出去一个边缘的部分并且没有分数据库,结果数据库仍旧是单点的,只是在很后期把数据库换成了主备架构(没做主从也没做读写分离啥的),没有走 k8s 而是用 Keepalived 去做了高可用。

矛盾纠纷多元化解

RuoYi-Cloud

这个就没用 k8s,而是用的 docker-compose 去部署。

工单流转逻辑本来打算用工作流引擎,但考虑到这个太过复杂且我们所有人都对工作流引擎不熟悉,结果还是去手写了。我自己牵头尝试集成 Mybatis Plus 和 Hibernate Validator,同步部分我则仍旧侵入了代码,但是是使用了 Spring 自己的本地消息总线以最小化侵入性。

GAP

如何离开虹信的

项目组改组,后端全裁了换成外包(现在估计要换成 AI 了)。

GAP 时干了什么

干眼症维持,全国旅游,然后主要时间是跑去学画画,同时去学了 Python,

既有问题

2. 项目中有遇到 OOM 的场景嘛?怎么去解决的

资源未释放倒是有,手动获取数据库连接(用于获取数据库类型供差异化SQL)后未释放

3.JVM 的内存模型

作为程序员直接看到的是堆和栈,堆内则按垃圾回收的需求分为新生代和老年代,然后还有堆外内存,包括 nio 编程中使用的直接内存以及方法区

4. 线程池的核心参数

核心线程数,最大线程数,非核心线程存活时间,阻塞队列,threadFactory,拒绝策略,

5.MySQL 的优化

表优化

从表上出发的话,我自己参与的项目中使用过 oracle 的内存表,然后使用过 mysql 的水平分表,按时间去水平分表

数据库优化

我自己没做过数据库优化,我参与的项目中有使用主备,按我知晓在架构上可以做读写分离,垂直分库。然后主要是在缓存上,调整连接数,线程数,innodb 缓存池大小……

6. MYSQL 实际开发中的索引失效的场景

我参加的业务中经常有模糊查询操作,然后还有排序使用非索引字段。

8. 私有方法事务的一致性怎么解决

指的是内部调用?内部调用的话 this 总是没有走代理的,所以内部调用时切面无法生效,因此事务就无法生效,无论是 JDK 代理还是 CGLIB;我用的解决方法是自己注入自己,比如叫 self,然后用这个 self 去调用(这时候注入的是正常经过 AOP 的对象)。

然后自己注入自己的话我尝试了一下会触发 Spring 的循环依赖的检查,需要加入 Lazy 注解或者使用 ObjectProvider 在运行时去注入(PostConstruct 中注入同样会触发循环依赖的检查)。但这里吊诡的是使用 Lazy 注释后会发现仍旧是可以注入的,Spring 也是屎山啊。

1. 分布式锁的死锁怎么解决

设置超时时间,同时要保证获取锁和设置超时时间这个过程是原子的。同时为了保证客户端活着时不超时可以有锁续期机制。如果可能会有自己等待自己的情况就使用重入锁。锁获取失败可以降级……

2. 消息队列消费失败了怎么办

得区分,倘若是编程问题比如序列化反序列化失败,业务逻辑错误就修正。

重试机制,死信队列,监控告警并人工干预。

2、Spring 中的 ioc 和 aop

IoC 就是控制反转,原本作为用户的话我需要自己创建我的依赖自己处理我的注入嘛,而有了 Spring 我就把创建和注入依赖这一步交给 Spring,Spring 自己去搞创建依赖然后维护依赖顺序之类,然后这也同时允许了基于注解的开发,因为 Spring 能知晓我某个类是用来干啥的比如 Controller,这样它就能在启动时给各个实例放到特殊的位置提供特殊的用途。然后因为 spring 附则创建依赖,它同时就也能对依赖本身去执行一定操作,而这就能实现 AOP,比如注解式事务就是用 AOP 实现的。

3、如何保证缓存数据一致性

主要是缓存更新策略的问题。我在实际业务中只采取过旁路缓存模式。

最常用旁路缓存模式(适用于读多写少)——读时先读缓存再读数据库然后设置缓存;写时删缓存。写数据库删缓存的时候可以延迟双删避免并发读时搞到脏数据

关于分布式缓存的一致性,考虑使用 redis 的发布订阅或者消息队列以保证能正确监听和主动让缓存失效,设置过期时间也非常重要。

强一致性和最终一致性……既然要强一致性,那为何还要缓存?强一致性就必须得事务,同步双写,读时只读缓存因为缓存和数据库强一致。

然后还有直写:写同时改缓存和 DB,读只读 DB,这能保证强一致性;此外还有异步回写——写只更新缓存,异步刷新数据库。

缓存问题

缓存穿透——数据库不存在的数据。

缓存击穿——热点 KEY 过期后大量请求全打到数据库。

缓存雪崩——大量 KEY 同时过期后大量请求全打到数据库。(雪崩是大量不同查询,击穿是大量相同查询)。

关于缓存穿透——这种一般是恶意攻击之类的,可以使用参数校验,布隆过滤器,空值缓存,监控告警等。

缓存击穿,因为这里所有请求访问的是同一个数据,这里可以使用互斥锁的方法保证只有一个线程更新缓存。

至于缓存雪崩,一般来说要差异化过期时间,或者做多级缓存,熔断降级等。

6、JVM 调优

JVM 调优的话得看具体情况,一般来说主要减少 Full GC 的频率最为重要,然后是降低 GC 停顿时间,提高吞吐量……

主要调整的就是堆内存设置,GC 算法。

1
2
3
4
5
6
7
8
9
# 基础配置
-Xms4g -Xmx4g # 初始堆=最大堆,避免动态调整
-Xmn2g # 新生代大小(建议占堆的 1/3~1/2)
-XX:MetaspaceSize=256m # 元空间初始大小
-XX:MaxMetaspaceSize=512m

# 老年代比例(默认 2:1)
-XX:NewRatio=2 # 新生代:老年代=1:2
-XX:SurvivorRatio=8 # Eden:Survivor=8:1:1

本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 协议 ,转载请注明出处!