杂记
解决 tail -f
碰到日志绕接时,停止工作的问题
日志绕接: 将零散日志整合, 重命名的过程
-
为啥会停止工作:
-
tail -f
是以文件描述符为标志来监测文件变化,当出现日志绕接时,会有重命名、创建文件的动作,这会促使文件描述符发生变化,至此tail -f
工作失效。 -
解决办法:
使用
tail -F
命令,该模式是以文件名来监测日志,而且文件不可访问时,还会重试,正好满足日志绕接的场景。
反射修改常量池里的String
- 为了安全, 很多规范要求敏感字符串用完后手动清空
// 通过反射来填充String字符串持有的char数组value,实现从内在中抹除敏感数据的目标。
public static void clear(String s) {
if (StringUtils.isEmpty(s)) {
return ;
}
Class<? extends String> clazz = s.getClass();
try {
Field field = clazz.getDeclaredField("value");
field.setAccessible(true);
Object obj = field.get(s); // 获取字符串的char[]数组
if (obj instanceof char[]) {
clear((char[]) obj); // 方法重载, 置为"000"
}
} catch (...) {
...
}
}
- 我们知道, 堆上池外
String
类底层是char[]
. 那如果这个字符串是属于字符串常量池,会发生什么?
String test = "123";
clear(test);
sout(test); // 打印"000"
sout("123"); // 打印"000"
sout("123".equals("000")); // true
String test2 = new String(char[]{'4', '5', '6'});
clear(test2);
sout(test2); // 打印"456"
sout(new String(char[]{'4', '5', '6'})); // 打印"456"
sout(new String(char[]{'4', '5', '6'}).equals("000")); // false
修改字符串池之外的字符串是不会发生上一步的奇怪BUG
- 如何保证入参都是字符串池之外的字符串呢?
// 添加一个地址比较的逻辑,用于检查该入参是否属于字符串池
// 此方法适用于jdk7以前, jdk8+的intern方法不一定拷贝到常量池, 即使s不在池中, s == s.intern()也可以为true
public static void clear(String s) {
if (StringUtils.isEmpty(s) || s == s.intern()) {
return ;
}
Class<? extends String> clazz = s.getClass();
try {
Field field = clazz.getDeclaredField("value");
field.setAccessible(true);
Object obj = field.get(s); // 获取字符串的char[]数组
if (obj instanceof char[]) {
clear((char[]) obj); // 方法重载, 置为"000"
}
} catch (...) {
...
}
}
Map的红黑树体现在哪?
- java.util.TreeMap#fixAfterDeletion
- java.util.TreeMap#fixAfterInsertion
- java.util.HashMap#rotateXXX
JVM类型推导
Integer i = null;
String.valueOf(i); // "null"
String.valueOf((Integer)null); // "null"
String.valueOf(null); // NPE
前两个传参有具体类型, 所以命中具体方法
public static String valueOf(Object obj) {
return (obj == null) ? "null" : obj.toString();
}
最后一个传参null无法推导, 同时命中上方法和
public String(char value[]) {
this.value = Arrays.copyOf(value, value.length);
}
JVM会倾向于选择形参类型更具体的public String(char value[])
方法, 故NPE
spring aop针对同一个方法有多个切面,默认优先级是怎么样的?
- 在同一切面类内, 按照切入点的定义顺序来织入
- 在不同的切面类内, 都实现了
Ordered
接口, 按切入点的Order数值从小到达织入. - 在不同的切面类内, 存在没实现
Ordered
接口的类, 则切入点的顺序不确定.
mybatis执行SQL查询列表,得不到结果时,返回为null还是空list/set?
为了接口幂等性, 返回空集合
spring中如何有两个相同beanId的bean声明,哪个生效?
- bean不存在覆盖一说,发现有重复beanId的bean时则不加载
- 如果明确要做spring bean覆盖,则应该使用spring父子容器, 这时应考虑加载顺序
- spring bean的加载顺序首先是根据依赖关系,生成DAG(有向无环图),然后依次加载。
- 通过指定
@Order
/@Primary
来主动干涉spring bean的创建优先级。 - 无任何扰动的情况下,spring加载路径中,越靠后的越先加载
使用并行流处理数据时,出现异常,在主线程会收到异常吗?
- 只要有一条数据出现异常,主线程就会收到异常。
- 不论哪一条数据执行有异常,都不影响其他数据的执行。
List<Integer> result =
List.of(1, 1, 2, 3, 4, 5, 6, 7, 8, 91)
.parallelStream()
.map(item -> {
System.out.println("map item [" + item + "]...");
if (item < 10) {
throw new RuntimeException(String.valueOf(item));
}
return item * 10;}
).toList();
如果线程里没有捕获异常,这个异常会被怎么处理?
- java.lang.Thread类提供方法
setUncaughtExceptionHandler
,用于指定未被捕获的异常的回调处理类,可使用它做一个兜底操作。但是前提是初始化Thread时需要设置该回调,如果没有设置的话,那未捕获的异常将会丢失。
最佳实践:
在线程的边界,捕获所有异常(Throwable
),保证出了异常后系统能有兜底,这样也不用依赖于线程创建时是否添加异常处理回调
现在有100万条黑名单ip列表, 当客户端携带ip地址进行访问时, 需要快速判断是否匹配, 怎么设计?
- 如果要求严格匹配, 则构造字典树, 时间复杂度O(1), 空间复杂度O(n)
- 如果允许一定误判率, 则构造布隆过滤器, 时间复杂度O(1), 空间复杂度O(n/8)
curl命令
在linux命令行, 使用curl命令调试某接口, 需要将url用引号包装, 防止linux将路径参数中的&解释为命令串行
# 错误, 会解释为
# curl -X POST http://localhost:3306/book?name=somebook和price=10两条命令
curl -X POST http://localhost:3306/book?name=somebook&price=10
# 正确
curl -X POST 'http://localhost:3306/book?name=somebook&price=10'
常用List的实现与选择
ArrayList
- 使用数组作为基础数据结构,实现了
RandomAccess
接口 - 大小调整,正常默认扩展原来大小的一半与需要添加的大小(针对
addAll
)的较大值, 使用System.arraycopy
方法将原来的数组内容拷贝到新数组中 - 无参构造初始化为空数组,当第一次添加元素时默认初始化大小10
- 数组最大大小为
Integer.MAX_SIZE-8
, 减8是因为在有的JVM实现中可能会在数组中添加一个头,超过大小会造成内存溢出,但是如果添加元素超过Interger.MAX_SIZE-8
而小于Interger.MAX_SIZE
时,数组的大小也会进行扩展,但是具体是否会引起异常就会据不同的JVM实现可能有所不同 - 保存了一个修改次数的计数
modCount
,每次添加删除元素都会增加该计数, 在使用迭代器遍历的时候,如果两次迭代modCount
发生变化则会抛出ConcurrentModificationException
,所以不能在迭代中修改列表的数据 - 线程不安全
LinkedList
- 双向链表作为基础数据结构, 实现了Queue接口,所以也是一个双向队列
- 使用下标访问的时候,LinkedList根据待访问的index确定从头指针开始查找还是从尾指针开始查找,保证了至多O(n/2)的时间复杂度
- 保存了一个修改次数的计数modCount,每次添加删除元素都会增加该计数, 在使用迭代器遍历的时候,如果两次迭代
modCount
发生变化则会抛出ConcurrentModificationException
,所以不能在迭代中修改列表的数据 - 线程不安全
Vector
- 与ArrayList不同的是Vector几乎所有的方法都添加了
synchronized
保护来保证线程安全,但是这样大大降低了访问的性能 - Vector构造方法提供了内部数组的
capacityIncrement
,如果不设置默认是倍增的 - Vector这种对所有方法的加锁行为是无效而且没有用的, 因为大多数时候我们对List的操作可能不会只是一个操作,会是一组操作, 需要事务型API
CopyOnWriteArrayList
- 当数据发生变化的时候会重新生成一个新的数组,并将原来数组的内容拷贝到新数组中,并将内部的数组引用改为新数组
- 使用
ReentrantLock
来保证多个线程的并行修改 - 迭代器遍历时可以对列表进行修改,不会抛出
ConcurrentModificationException
,因为修改的时候数组数据会变成新的数组,而遍历的仍然是老数组, 不是同一个modCount
- 修改添加操作会非常耗时,所以只有当至少90%以上的操作都是读操作,很少有写操作的时候才会使用
代码重构
- 处理逻辑不变, 待处理数据可变 -> 抽取数据
- 待处理数据不变, 处理逻辑可变 -> 用函数式接口抽取出算子, 将算子作为参数
class A {
public Object foo1(String param1, String param2) {
if (Collection.size() == 1) {
Collection.get(0).util1(param1, param2);
}
for(var unit : Collection.get()) {
unit.util1(param1, param2);
}
return ...;
}
public Object foo2(Integer param1, String param2) {
if (Collection.size() == 1) {
Collection.get(0).util2(param1, param2);
}
for(var unit : Collection.get()) {
unit.util2(param1, param2);
}
return ...;
}
public Object foo3() {
if (Collection.size() == 1) {
Collection.get(0).util3();
}
for(var unit : Collection.get()) {
unit.util3();
}
return ...;
}
}
class B {
public Object foo1(String param1, String param2) {
return bar(unit.getClass()::util1);
}
public Object foo2(Integer param1, String param2) {
return bar(unit.getClass()::util2);
}
public Object foo3() {
return bar(unit.getClass()::util3);
}
private <T> T bar(Function<unit.getClass(), T> func) {
if (Collection.size() == 1) {
func.apply(Collection.get(0));
}
for(var unit : Collection.get()) {
func.apply(unit);
}
return T;
}
}
java基本类型占空间大小
- 对于局部变量的基本类型, 因为使用栈帧里的局部变量表的slot存储, 所以long, double占 2slot(8字节), 其余类型(包括引用)都 1slot(4字节, 32位机|8字节, 64位机)
- 对于成员变量的基本类型, 因为是在堆上动态分配空间, 所以boolean/byte 1字节, char/short 2字节, int/float 4字节, double/long 8字节
新建对象的方法
- new
- 反射
- clone
- 反序列化
- Unsafe.allocateInstance
volatile原理?Happens-before内存模型?
如果操作 X happens-before 操作 Y,那么 X 的结果对于 Y 可见。
-
happens-before模型包括如下几种:
- 线程内部字节码顺序, 注意如果操作没有依赖关系, jvm在编译阶段可能已将字节码重排
- unlock -> lock
- volatile写 -> volatile读
- Thread.start() -> run()的首个操作操作
- run()的末尾操作 -> Thread.isAlive()/Thread.join()
- Thread.interrupt() -> catch InterruptedException/Thread.interrupted()
- 构造器的末尾操作 -> finalizer的首个操作
-
如下示例代码, 由于方法局部变量之间没有依赖性, 所以在多线程情况下的重排可能引发意外结果
int a=0, b=0;
public void method1() {
int r2 = a;
b = 1;
}
public void method2() {
int r1 = b;
a = 2;
}
- 使用volatile修饰b, 强制规定b的写操作 先于 b的读操作, 结果一定为r1 = 1, r2 = 0;
- 本质上是利用happens-before规定了多线程的运行顺序
int a=0;
volatile int b=0;
public void method1() {
int r2 = a; // 1. read a
b = 1; // 2. write b
}
public void method2() {
int r1 = b; // 3. read b
a = 2; // 4. write a
}
- happens-before底层实现原理: 内存屏障
- 比如规则: volatile写 -> volatile读, 那么volatile写读之间的内存访问应该被屏蔽, 即禁止jvm将 访问内存字节码 重排到volatile写读之间
- 实现上, 并没有真正禁止重排, 而是遇到volatile写就强制刷新缓存, 将当前volatile值刷回内存, 同时使其他线程持有的同一缓存行失效(类似于先写DB后删redis缓存的强一致性策略), 保证了即使重排字节码, 也能去内存读到最新值的效果
- volatile缺点: 不保证原子性, 如果频繁刷新缓存性能会退化(伪共享问题), 因此只适用于读多写少, 单线程写场景
synchronized锁升级
每个对象都拥有字段: markword对象头, 其末尾3位标识该对象的锁状态
- 无锁(001)表示所有线程都能访问
- 偏向锁(101, 默认)针对的是锁仅会被同一线程持有的情况
- 只会在第一次请求时采用 CAS 操作,在锁对象的markword记录下当前线程的地址
- 即使持有线程已经结束访问, 也不会主动清除标记, 方便这个线程重复加锁时直接放行
- 如果其他线程申请加锁, 则判断markword记录的当前持有线程是否存活
- 如果持有线程已终止, 则改线程地址为新的申请线程
- 否则, 表明出现了多线程竞争, 针对单线程的偏向锁已经不能满足, 升级为轻量级锁
- 轻量级锁/自旋锁(00)针对的是多个线程在不同时间段申请同一把锁, 且每个线程持有时间较短的情况
- 为了减少阻塞操作带来的上下文切换, 竞争线程通过自旋操作保持在用户态, 并不断CAS尝试修改标志为00
- 这在持有时间较短的情况下是可以接受的(比如本地加锁), 但如果持有时间很长(比如加锁后RPC), 空转自旋会消耗CPU, 影响吞吐
- 因此, 当竞争线程自旋超过一定次数(根据历史自旋次数决定), 或竞争线程很多时, 表示持有时间会较长, 让竞争者都阻塞, 减少CPU消耗, 升级为重量级锁
- 重量级锁(10)会阻塞、唤醒请求加锁的线程。它针对的是多个线程同时竞争同一把锁的情况。
- 类似threadlocal, 每个对象都拥有字段: 锁计数器, 持有者指针, 阻塞队列
- 当进入monitorEnter, 如果计数器=0, 表明可以加锁; 如果计数器>0, 则继续判断锁持有者是否就是访问者(可重入锁), 如果是则可以加锁, 否则进入阻塞队列
- 当进入monitorExit, 计数器--, 如果计数器减到0, 表示锁释放了, 从阻塞队列中取出线程尝试加锁
泛型 & 类型擦除
- 对于
List<? extends XXX>
类型的list, 其add和get操作的对象都是XXX类型, 同时单独保存?的类型, 需要用到时再强转为?
· 分享一个免费、快速、无限量使用的满血 DeepSeek R1 模型,支持深度思考和联网搜索!
· 使用C#创建一个MCP客户端
· ollama系列1:轻松3步本地部署deepseek,普通电脑可用
· 基于 Docker 搭建 FRP 内网穿透开源项目(很简单哒)
· 按钮权限的设计及实现