
今天是Java技术八股学习计划的第33天。连着一整个月高强度刷题我发现一个很有意思的现象网上铺天盖地的Java面试题合集、八股文整理翻来覆去总绕不开那几个大块头——集合、并发、JVM、Spring、MySQL、Redis。越往后学越明白八股这东西背只是最浅的一层真正的分水岭在于你能不能把一个考点拆成“为什么这样设计”和“生产环境里会怎么踩坑”。之前20多天我基本在啃JVM和并发今天把重点切到Redis顺带把前两天积压的Java环境配置、Lombok编译报错、内存溢出案例一起复盘掉这一天的信息量确实不小。这篇文章是今天的完整学习实录。里面包含了我实际跑过的代码片段、真实遇到的报错信息、排查思路还有一些只靠刷题根本碰不到的经验判断。如果你也处于Java面试准备期或者工作中正在用RedisTemplate做缓存、库存扣减、分布式锁这类场景可以参考一下今天的复盘。1. 为什么是Redis库存扣减成了Java八股的常客1.1 一个看似简单却总翻车的场景今天先是整理热搜词的时候注意到和Java相关的热门搜索里“库存扣减 八股”和“java中redis使用redistemplate的increment()报错不是integer or out of range”被频繁搜到。这两个词放在一起其实就是一个典型场景用Redis做秒杀、抢购、库存预扣结果代码跑起来报错或者数据对不上。库存扣减为什么爱用Redis最简单直白的理由数据库扛不住高频写。一张订单表在秒杀瞬间可能同时收到几千个扣减请求直接怼到MySQL的行锁上很容易把数据库拖垮。Redis是单线程模型所有命令在服务端是串行执行的本身就天然具备原子性所以用Redis来扛库存这种高频写操作是互联网项目里的常规做法。但常规做法不等于没有坑。最常见的错误就是先GET再DECRInteger stock redisTemplate.opsForValue().get(stock:1001).intValue(); if (stock 0) { redisTemplate.opsForValue().decrement(stock:1001); }这段代码的问题一眼就能看出来GET和DECR之间不是原子的。在两个线程同时读到stock1的情况下两个线程都会进入if块各自执行一次decrement库存就会变成-1。这就是典型超卖。面试官问库存扣减很多情况下想听的并不是“怎么用Redis”而是你有没有意识到原子性和并发安全这些关键点。1.2 Redis考点全景从数据结构到一致性刷了这么多天八股我把Redis在Java面试里的考点整理成了几条主线。数据结构String、Hash、List、Set、ZSet是基本功几乎必问然后是过期策略、内存淘汰、持久化RDB和AOF、主从复制、哨兵和集群再往深一点就是分布式锁、缓存穿透/击穿/雪崩、缓存和数据库一致性以及Lua脚本。今天的重点放在缓存一致性和分布式锁上。原因很简单这两个方向不仅仅是面试高频更是工作中最容易出线上故障的地方。很多基础面试题只看《Redis设计与实现》或者小林coding上的图解就能答个八九不离十但一致性、分布式锁这种题如果你没有实际处理过问题回答会特别空面试官追问两轮就露馅了。2. 今日实战复盘RedisTemplate 库存扣减的三种写法2.1 用 increment() 扣库存时的报错现场先把这个报错讲透。很多人搜“redistemplate increment() 报错不是integer or out of range”其实完整的Redis错误提示是ERR value is not an integer or out of range意思是说你执行INCR/DECR命令的时候目标key里存的value不是整数或者超出了64位有符号整数的范围。网上这类报错帖子特别多我归纳了一下常见原因无非这么几类。第一类是序列化器不一致。Spring Data Redis默认的RedisTemplate使用的序列化器是JdkSerializationRedisSerializer它会把对象序列化成二进制格式再存进Redis。如果你用StringRedisTemplate存了一个值进去又用RedisTemplate去执行increment()这时候Redis读到的二进制内容不是纯数字字符串自然报错。解决办法是统一序列化器比如都用StringRedisSerializer或者给RedisTemplate显式设置key和value的序列化方式。RedisTemplateString, Object redisTemplate new RedisTemplate(); redisTemplate.setKeySerializer(new StringRedisSerializer()); redisTemplate.setValueSerializer(new GenericJackson2JsonRedisSerializer()); redisTemplate.setConnectionFactory(connectionFactory); redisTemplate.afterPropertiesSet();第二类是value本身不是数字。如果你在线下调试时往同一个key里存过“abc”或者其他非数字类型之后再执行increment()同样会报这个错。这时候去Redis客户端里查一下key的type和value就好排查起来其实很快。第三类是溢出。64位有符号整数的范围是-9223372036854775808到9223372036854775807正常业务库存根本不可能触及这个边界但如果你拿Redis做自增ID并且初始值设置得不合理还是有可能踩到。2.2 从超卖到不超卖原子命令与 Lua 脚本回到库存扣减本身。上面那段GET-DECR的写法是不对的那正确的打开方式是什么最直接的办法就是用原子命令。既然Redis单命令本身就是原子的那库存扣减直接写成这样就行Long remain redisTemplate.opsForValue().decrement(stock:1001); if (remain 0) { // 扣超了需要回补并返回失败 redisTemplate.opsForValue().increment(stock:1001); return 已售罄; }decrement()是原子操作底层对应Redis的DECR命令服务端串行执行不会有两个线程同时读到同一个值的问题。扣减之后判断返回值如果小于0说明库存超扣了再把库存加回去同时给用户返回“已售罄”。不过这里有个细节值得注意先扣减失败再回补这个回补动作本身也可能失败而且中间隔着网络严格来说并不是100%可靠。之前有一次压测我就遇到过回补那一下因为连接池耗尽没执行成功导致库存少了。如果你的库存是强一致要求更稳妥的是用Lua脚本把整个判断逻辑放在Redis服务端执行local stock tonumber(redis.call(get, KEYS[1])) if stock nil or stock 0 then return -1 end redis.call(decr, KEYS[1]) return stock - 1在Java里用Spring Data Redis执行Lua脚本也很方便Autowired private StringRedisTemplate stringRedisTemplate; public boolean mightDecrStock(String key, long quantity) { DefaultRedisScriptLong script new DefaultRedisScript(); script.setLocation(new ClassPathResource(stock.lua)); script.setResultType(Long.class); Long result stringRedisTemplate.execute( script, Collections.singletonList(key), String.valueOf(quantity) ); return result ! null result 0; }Lua脚本这种方式判断和扣减这两个操作在同一个Redis请求里完成中间不会插入任何其他命令并发安全是绝对有保障的。唯一的成本是要维护脚本文件但这在现代项目里属于正常操作。这里补充一个容易被忽略的点用Redis做库存扣减一般还需要做“最终库存同步”。Redis里的库存本质上是“预扣库存”或者“可抢名额”真正的订单数据还是要落到数据库靠数据库的唯一索引或版本号来做最终一致性兜底。Redis负责挡流量MySQL负责出账各司其职。2.3 缓存与数据库的一致性处理库存扣减场景里缓存和数据库的一致性是个绕不开的话题。常见的方案有Cache Aside Pattern旁路缓存、延迟双删、订阅Binlog同步等。Cache Aside是主流项目中最常见的做法。读的时候先读缓存读不到再读数据库然后把数据写回缓存写的时候先更新数据库再删除缓存。为什么是先更新数据库再删缓存而不是直接更新缓存因为直接更新缓存有两个问题一个是并发写时后写的把先写的覆盖了但数据库的顺序和缓存顺序不一定一致另一个是缓存里可能还存着一些不常读的冷数据主动更新是白费力气。删缓存则简单得多等下次读的时候再回填就行。延迟双删是Cache Aside的一种加强版本更新数据库后先删一次缓存过几百毫秒再删一次。它解决的是读请求在删除缓存前把旧值回填进缓存的问题。第一次删除之后如果并发读已经把旧值写回去了第二次删除就能把这个“脏缓存”清掉。但这个方案在面试里说说是加分项在生产里其实挺别扭的因为等待时间很难定删早了没效果删晚了影响吞吐。如果是新项目我更推荐用Binlog订阅方案应用只负责更新数据库另外一个组件订阅MySQL的Binlog变化解析出变更事件后同步清理对应缓存或者刷新缓存。这种方式把缓存操作从业务代码里剥离了出来逻辑干净很多。当然架构复杂度会上升团队规模不够大、业务没那么核心的情况下用延迟双删也够了。3. 分布式锁与缓存失效Redis 八股的深水区3.1 setnx 与过期时间的原子性问题分布式锁是Java面试里的保留节目。两三年前大家还停留在“用SETNX加锁用完DEL释放锁”的阶段现在面试官已经会连环追问设置过期时间了吗万一锁过期了但业务没执行完怎么办删除锁的时候误删了别人的锁怎么办基础版加锁命令是SET lock_key unique_value NX EX 30其中NX表示只有当key不存在时才能设置成功EX 30表示锁的过期时间为30秒。这里最经典的坑就是“把设置锁和设置过期时间分成两步”即先SETNX再EXPIRE。这个写法现在依然有人用但它是错的——两步之间不是原子的一旦第二步执行前进程崩溃锁永远不会过期别人也永远拿不到锁。一定要用一条命令把NX和EX同时带上。释放锁的时候推荐用Lua脚本先校验再删除if redis.call(get, KEYS[1]) ARGV[1] then return redis.call(del, KEYS[1]) else return 0 end为什么要校验因为用唯一标识比如UUID作为锁的value才能保证删的是自己持有的锁。假如不加校验直接DEL就可能出现这种情况线程A持有锁后因为GC卡顿导致锁过期了线程B拿到锁开始干活A恢复了直接DEL把B的锁删了此时线程C又拿到锁锁就失效了。这种问题在压测环境下很容易复现。至于锁过期时间网上有各种推荐值但我更愿意说一个思路评估你被锁保护的这段代码的最大执行时间在这个基础上留出足够余量然后设置过期时间。如果你实在不放心可以用Redisson它的看门狗机制会在业务没执行完时自动续期确实能解决锁过期问题但它也有自己的复杂性后面会说。3.2 RedLock 到底该不该用RedLock是Redis作者提出的一种分布式锁算法思路是同时在多个独立Redis节点上加锁只有当超过一半节点加锁成功才认为锁获取成功。听起来很健壮但行业内对它争论很大很多人认为在大多数场景下没有必要用甚至在一些极端情况下RedLock可能并不像想象的那么安全。我个人的态度是除非你的业务真的横跨多个独立机房并且单机Redis锁失效会导致灾难性后果否则不要为了面试题里那点“亮点”在生产环境强行上RedLock。原因有几个一是它需要奇数台独立Redis实例成本和维护难度都不低二是RedLock本身依赖时间假设如果某个节点发生时钟跳跃也可能破坏安全性三是多数分布式锁场景里业务系统自己的“幂等”“重试”“兜底”机制比锁更关键。分布式锁的选择需要结合业务风险来评估。允许偶发重复执行的活动发放业务用普通的Redis锁就够了涉及资金、订单状态的强一致场景就要考虑DB乐观锁、ZooKeeper或etcd这类强一致协调服务。Redis的锁本质上是AP模型的产物ZooKeeper的锁更偏向CP模型各自适合不同场景。3.3 缓存穿透、击穿、雪崩缓存穿透、缓存击穿、缓存雪崩这三个概念历来是八股题里的常客但很多人背完定义却不知道怎么应对。“穿透”是查一个根本不存在的数据Redis没缓存数据库也没有每次请求都打到DB“击穿”是某个热点缓存key在失效的瞬间大量请求同时打到DB“雪崩”则是大量key在同一时间段集中失效DB压力瞬间爆掉。应对穿透比较常见的办法是缓存空值或者用布隆过滤器。缓存空值有个细节空值缓存的过期时间要短一点否则大量空key堆积在Redis里也很占内存。应对击穿核心思路是“让并发请求不要同时回源”可以给缓存加互斥锁也可以把热点key的过期时间拉长并配合后台异步刷新。应对雪崩一般把缓存过期时间设置成不同时间段的随机值避免同一时间集体过期同时对热点缓存做永久key后台更新策略。今天为了加深印象我写了一个简单的互斥锁防击穿的伪代码核心逻辑是这样的Object value redisTemplate.opsForValue().get(key); if (value ! null) { return value; } String lockKey lock: key; if (tryLock(lockKey, 3)) { try { value database.query(key); redisTemplate.opsForValue().set(key, value, 300, TimeUnit.SECONDS); return value; } finally { releaseLock(lockKey); } } else { Thread.sleep(100); return redisTemplate.opsForValue().get(key); }这种方式在某些场景下能挡住大流量对DB的冲击但整个流程写起来比单纯查缓存复杂不少。如果在面试里能主动区分“穿透/击穿/雪崩”对应的方案差异再引出自己项目中某个缓存方案为什么用了随机过期时间或布隆过滤器面试官一般会觉得你有真实设计意识。4. 今天顺带处理的一批 Java 环境与 JVM 问题4.1 环境变量配置常见问题学习群里今天好几个人同时在问Java环境变量配置原因很现实换了一台新电脑或者装了新JDKjava -version能跑通但javac一执行就报“不是内部或外部命令”。这问题的根源基本都是PATH没配好。正确的环境变量配置一般是这么三步新建系统变量JAVA_HOME值指向JDK安装目录比如C:\Program Files\Java\jdk-17。注意不要带\bin。编辑系统变量PATH在最前面添加一行%JAVA_HOME%\bin。有的老教程还让你配CLASS_PATH如果你用的是Java 8以上版本不用配也能编译执行配了反而容易引起困惑。配完之后一定要新开一个命令行窗口再测试不然环境变量不会自动刷新。还有一个容易被忽略的坑如果你机器上装了多个JDKjava -version显示的版本和你预期不一致多半是PATH里某个Java路径优先级更高。用where java或者which java查一下实际解析的路径就能定位问题。这里我还想延伸一下环境变量问题虽然看起来low但在面试现场如果问你“怎么排查java命令失效”你能讲出“先which java看路径再echo $JAVA_HOME看变量最后检查PATH顺序”比你光背Spring Bean的生命周期可能更能让面试官记住你——因为你像个真正处理过线上环境的人。4.2 NoClassDefFoundError 和 OutOfMemoryError今天热词里有一条特别典型的JVM报错出现在老项目迁移到新JDK的场景下Exception in thread main java.lang.NoClassDefFoundError: java/applet/Applet这个问题在新版JDK比如Java 11、Java 17上比较常见。java.applet.Applet在Java 9以前是JDK自带的基础库但从Java 9开始Applet API被标记为废弃到了Java 11就被正式移除了。如果你的老项目里某段代码直接依赖了Applet类编译时没发现运行时就会NoClassDefFoundError。应对思路有几种一是把项目切换到兼容的JDK版本治标不治本二是找到并替换掉依赖Applet的那段代码换成更适合现代Java的GUI或者别的能力三是如果依赖的是某个第三方库内部引用了Applet API那就要升级第三方库。不要因为这种报错就怀疑自己的环境坏了它纯粹是API变更导致的兼容性问题。另外今天热词里还有一条java: OutOfMemoryError: Insufficient memory兄弟关键词是outofmemoryerror: insufficient memory。很多人在IDEA里遇到这个报错第一反应是加-Xmx但有时候加了也没用。比如你在IDEA的VM options里加了-Xmx4g但你的电脑物理内存就8GIDEA本身、数据库、浏览器、Docker都在抢内存JVM尝试申请4G堆空间时操作系统给不出来还是会提示insufficient memory。这时候与其盲目加大-Xmx不如先用任务管理器看看还有多少可用内存分析一下到底是堆不够还是机器本身内存吃紧。4.3 Lombok 编译报错与编译器版本再记一条今天群里踩的坑Lombok和JDK版本不兼容导致的报错java: you arent using a compiler supported by lombok, so lombok will not work...这个报错一出很多人第一反应是“Lombok版本太老升级一下就行”。但实际排查起来没那么简单。Lombok是通过注解处理器在编译期修改AST抽象语法树来生成getter/setter的它对JDK编译器的内部API依赖非常深。JDK每次大版本升级Lombok都要跟着做适配如果本地装了JDK 17Lombok还停留在1.16.x版本就会报这个错。处理方式有三个方向优先升级Lombok版本到新版本如果你用Maven或Gradle构建检查项目指定的编译Java版本和当前JDK版本是否一致实在不行检查IDE的编译选项里是否强行指定了某个旧版编译器。这一类问题很能说明“环境配置不等于只装一个JDK”这件事版本之间的兼容性匹配是每个Java开发者都要养成的直觉。5. 把八股学成肌肉记忆的几个土办法5.1 光背不行得用代码验证我刷到第33天最大的感受是真不能再靠“背”了。八股文里的答案如果只是背下来面试官一深挖基本露馅。比较好的排查方式是学到一个知识点就写一段最小代码去验证它的行为。比如学Redis的过期策略我就启动一个Spring Boot项目用RedisTemplate写入不同TTL的key观察内存淘汰时哪个先被驱逐学JVM堆内存就写一个不断创建对象的循环用JConsole看堆的曲线变化。当天验证当天总结记忆会特别牢。因为你在写代码的过程中会逼着自己去查API、看源码、调参数这些动作本身就是一次主动学习比被动看文章有效得多。5.2 讲给别人听比刷十遍更有效费曼学习法在八股学习这件事上是真有用。我现在的习惯是每天学完当天内容后找一个不太懂的同事或朋友用三五分钟把今天的主题讲给他听。如果他提的问题我答不上来第二天就回去查资料补漏。这个过程比单纯做笔记更痛苦但效果也更好。因为只有当你用口语把一段技术逻辑讲顺了你才真的掌握了它。今天讲Lua脚本防超卖的时候我就被问住了为什么不在Redis事务里执行这个确实是个好问题。Redis的MULTI/EXEC事务虽然能保证一批命令串行执行但它不支持“根据前一个命令的结果来决定后面是否执行”这种逻辑而Lua脚本在服务端可以做条件判断两者能力边界完全不同。这个问题一出来我立刻觉得今天学得更透了。5.3 常见问题和速查表最后分享一份我最近手动整理的“八股学习状态自检表”用来判断自己对一个知识点是真的懂了还是只是在背诵检查维度合格标准概念解释能脱离资料向别人讲清楚是什么、解决什么问题底层原理能说出大概的内部机制比如Redis为什么单线程还快使用场景能举出自己写过或见过的实际案例坑点与边界能说出至少两个常见错误或不适合使用的场景代码验证能自己写出最小示例确认结论不是猜的如果某个知识点在这五个维度上都能过一遍就不需要再反复刷同类型的题了。相反如果只是记住了“Redis是单线程的所以快”却答不上为什么快、快在哪些场景、哪些场景不占优势那这个知识点大概率还没真正消化。6. 今天的最后一点心得每次刷到分布式锁、缓存一致性这类题的时候我都在想八股文的意义不在于让你背标准答案而是逼着你去想象一个大规模系统的运行过程去想它的瓶颈在哪里、崩溃的时候会发生什么。第33天学到Redis这里我越来越确信面试官真正想招的是那种能把“缓存穿透”和“库存超卖”拿到生产环境里冷静分析的人。如果你也在准备Java面试卡在某个知识点上背不死记不住不妨换个思路打开IDEA写一个Demo把那个问题复现出来再一步步解决它。今天文章里所有关于RedisTemplate、Lua脚本、分布式锁、Lombok兼容性的内容全部建议你动手敲一遍别只看代码。把“背八股”变成“做实验”学习的效率会完全不一样。我也还在路上明天Day34继续推进。