- 01丨使用了并发工具类库,线程安全就高枕无忧了吗?
- 02丨代码加锁:不要让“锁”事成为烦心事
- 03丨线程池:业务代码最常用也最容易犯错的组件
- 04丨连接池:别让连接池帮了倒忙
- 05丨HTTP调用:你考虑到超时、重试、并发了吗?
- 06丨20%的业务代码的Spring声明式事务,可能都没处理正确
- 07丨数据库索引:索引并不是万能药
- 08丨判等问题:程序里如何确定你就是你?
- 09丨数值计算:注意精度、舍入和溢出问题
- 10丨集合类:坑满地的List列表操作
- 11丨空值处理:分不清楚的null和恼人的空指针
- 12丨异常处理:别让自己在出问题的时候变为瞎子
- 13丨日志:日志记录真没你想象的那么简单
- 14丨文件IO:实现高效正确的文件读写并非易事
- 15丨序列化:一来一回你还是原来的你吗?
- 16 | 用好Java 8的日期时间类,少踩一些“老三样”的坑
- 17丨别以为“自动挡”就不可能出现OOM
- 18丨当反射、注解和泛型遇到OOP时,会有哪些坑?
- 19丨Spring框架:IoC和AOP是扩展的核心
- 20丨Spring框架:框架帮我们做了很多工作也带来了复杂度
Java业务开发常见错误100例(代码篇-2) - 个人文章 - SegmentFault 思否
Java高手笔记之业务开发常见错误100例 - 简书 (jianshu.com)
01丨使用了并发工具类库,线程安全就高枕无忧了吗?
1.使用 ThreadLocal 来缓存数据,以为 ThreadLocal 在线程之间做了隔离不会有线程安全问题,没想到线程重用导致数据串了。请务必记得,在业务逻辑结束之前清理ThreadLocal 中的数据。
原因:线程可能重用,导致ThreadLocal中的数据会串
解决︰用完及时清空数据,比如可以自定义HandlerInterceptorAdapter,在preHandle 的时候去设置ThreadLocal,在 afterCompletion时去remove
2.认为使用了 ConcurrentHashMap 就可以解决线程安全问题,没对复合逻辑加锁导致业务逻辑错误。如果你希望在一整段业务逻辑中,对容器的操作都保持整体一致性的话,需要加锁处理。
原因:ConcurrentHashMap只能保证提供的原子性读写操作(比如putlfAbsent、computelfAbsent、replace、compute)是线程安全的
解决︰如果需要确保多个原子性操作整体线程安全,需要自己加锁解决
补充:诸如size、isEmpty和containsValue 等聚合方法,在并发情况下可能会反映ConcurrentHashMap 的中间状态。因此在并发情况下,这些方法的返回值只能用作参考,而不能用于流程控制
3.没有充分了解并发工具的特性,还是按照老方式使用新工具导致无法发挥其性能。比如,使用了ConcurrentHashMap,但没有充分利用其提供的基于 CAS 安全的方法,还是使用锁的方式来实现逻辑。
原因:仍然像HashMap那样使用加锁的方式,来使用ConcurrentHashMap
解决︰考虑使用computelfAbsent、putlfAbsent、getOrDefault等API来提升性能
4.CopyOnWriteArrayList虽是一个线程安全的ArrayList,它原理是 写时复制,因此适用于 读多写少的 业务场景下
原因::CopyOnWriteArrayList每次修改复制一份数据
解决︰读多写少的场景才考虑CopyOnWriteArrayList,写多的场景考虑ArrayList
02丨代码加锁:不要让“锁”事成为烦心事
- 使用 synchronized 加锁虽然简单,但我们首先要弄清楚共享资源是类还是实例级别 的、会被哪些线程操作,synchronized 关联的锁对象或方法又是什么范围的。
- 加锁尽可能要考虑粒度和场景,锁保护的代码意味着无法进行多线程操作。对于 Web 类型的天然多线程项目,对方法进行大范围加锁会显著降级并发能力,要考虑尽可能 地只为必要的代码块加锁,降低锁的粒度;而对于要求超高性能的业务,还要细化考虑锁的 读写场景,以及悲观优先还是乐观优先,尽可能针对明确场景精细化加锁方案,可以在适当 的场景下考虑使用 ReentrantReadWriteLock、StampedLock 等高级的锁工具类。
- 业务逻辑中有多把锁时要考虑死锁问题,通常的规避方案是,避免无限等待和循环等 待。
- 此外,如果业务逻辑中锁的实现比较复杂的话,要仔细看看加锁和释放是否配对,是否有遗 漏释放或重复释放的可能性;并且要考虑锁自动超时释放了,而业务逻辑却还在进行的情况 下,如果别的线线程或进程拿到了相同的锁,可能会导致重复执行。
- 如果你的业务代 码涉及复杂的锁操作,强烈建议 Mock 相关外部接口或数据库操作后对应用代码进行压 测,通过压测排除锁误用带来的性能问题和死锁问题。
03丨线程池:业务代码最常用也最容易犯错的组件
- Executors 类提供的一些快捷声明线程池的方法虽然简单,但隐藏了线程池的参数细节。因此,使用线程池时,我们一定要根据场景和需求配置合理的线程数、任务队列、拒绝策略、线程回收策略,并对线程进行明确的命名方便排查问题。
- 既然使用了线程池就需要确保线程池是在复用的,每次 new 一个线程池出来可能比不用线程池还糟糕。如果你没有直接声明线程池而是使用其他同学提供的类库来获得一个线程池,请务必查看源码,以确认线程池的实例化方式和配置是符合预期的。
- 复用线程池不代表应用程序始终使用同一个线程池,我们应该根据任务的性质来选用
不同的线程池。特别注意 IO 绑定的任务和 CPU 绑定的任务对于线程池属性的偏好,如果
希望减少任务间的相互干扰,考虑按需使用隔离的线程池。
04丨连接池:别让连接池帮了倒忙
连接池结构示意图:(常用连接池有:Redis 连接池、HTTP 连接池、数据库连接池)
- 连接池实现方式:客户端 SDK 实现连接池的方式,包括池和连接分离、内部带有连接池和非连接池三种。要
正确使用连接池,就必须首先鉴别连接池的实现方式。比如,Jedis 的 API 实现的是池和连接分离的方式,而 Apache HttpClient 是内置连接池的 API。 - 使用姿势:一是确保连接池是复用的,二是尽可能在程序退出之前显式关闭连接池释放资源。
- 连接池参数配置:最重要的是最大连接数,许多高并发应用往往因为最大连接数不
够导致性能问题。但最大连接数不是设置得越大越好,而是够用就好。
05丨HTTP调用:你考虑到超时、重试、并发了吗?
- 理解 连接超时 和 读取超时的区别,学会如何设置 合适的 超时参数。此外,在使用诸如 Spring Cloud Feign 等框架时务必确认,连接和读取超时参数的配 置是否正确生效。
- 对于重试,因为HTTP 协议认为 Get 请求是数据查询操作,是无状态的,又考虑到网络出 现丢包是比较常见的事情,有些 HTTP 客户端或代理服务器会自动重试 Get/Head 请求。 如果你的接口设计不支持幂等,需要关闭自动重试。但更好的解决方案是,应该是 遵从 HTTP 协议的建议来使用合适的 HTTP 方法。
06丨20%的业务代码的Spring声明式事务,可能都没处理正确
因为配置不正确,导致方法上的事务没生效,@Transactional 生效原则 :
除非特殊配置(比如使用 AspectJ 静态织入实现 AOP),否则只有定义在 public 方法上的 @Transactional 才能生效。
原因:Spring 默认通过动态代理的方式实现 AOP,对目标方法进行增强,private 方法无法代理到, Spring 自然也无法动态增强事务处理逻辑。
- 必须通过代理过的类从外部调用目标方法才能生效
- 因为异常处理不正确,导致事务虽然生效但出现异常时没回滚,Spring 默认只会对标记 @Transactional 注解的方法出现了 RuntimeException 和 Error 的时候回滚,如果我们的方法捕获了异常,那么需要通过手动编码处理事务回滚。如果希望 Spring 针对其他异常也可以回滚,那么可以相应配置 @Transactional 注解的 rollbackFor 和noRollbackFor 属性来覆盖其默认设置。
- 如果方法涉及多次数据库操作,并希望将它们作为独立的事务进行提交或回滚,那么我们需要考虑进一步细化配置事务传播方式,也就是 @Transactional 注解的Propagation 属性。
07丨数据库索引:索引并不是万能药
InnoDB 是如何存储数据的?
虽然数据保存在磁盘中,但其处理是在内存中进行的。为了减少磁盘随机读取次数,
InnoDB 采用页而不是行的粒度来保存数据,即数据被分成若干页,以页为单位保存在磁盘
中。InnoDB 的页大小,一般是 16KB。
各个数据页组成一个双向链表,每个数据页中的记录按照主键顺序组成单向链表;每一个数
据页中有一个页目录,方便按照主键查询记录。数据页的结构如下:- 页目录通过槽把记录分成不同的小组,每个小组有若干条记录。如图所示,记录中最前面的 小方块中的数字,代表的是当前分组的记录条数,最小和最大的槽指向 2 个特殊的伪记 录。有了槽之后,我们按照主键搜索页中记录时,就可以采用二分法快速搜索,无需从最小 记录开始遍历整个页中的记录链表。
聚簇索引和二级索引
- InnoDB 使用 B+ 树,既可以保存实际数据,也可以加速数据搜索,这就是聚簇索引。由于数据在物理上只会保存一份,所以包含实际数据的聚簇索引只能有一个
- 为了实现非主键字段的快速搜索,就引出了二级索引,也叫作非聚簇索引、辅助索引。二级 索引,也是利用的 B+ 树的数据结构
不是所有针对索引列的查询都能用上索引
- 第一,索引只能匹配列前缀
- 第二,条件涉及函数操作无法走索引
- 第三,联合索引只能匹配左边的列
解决几个误区:
- 考虑到索引的维护代价、空间占用和查询时回表的代价,不能认为索引越多 越好。索引一定是按需创建的,并且要尽可能确保足够轻量。
- 不能认为建了索引就一定有效,对于后缀的匹配查询、查询中不包含联合索 引的第一列、查询条件涉及函数计算等情况无法使用索引。此外,即使 SQL 本身符合索引 的使用条件,MySQL 也会通过评估各种查询方式的代价,来决定是否走索引,以及走哪个 索引。
08丨判等问题:程序里如何确定你就是你?
- 首先,我们要注意 equals 和 == 的区别。业务代码中进行内容的比较,针对基本类型只能使用 ==,针对 Integer、String 在内的引用类型,需要使用 equals。Integer 和 String的坑在于,使用 == 判等有时也能获得正确结果(JVM缓存,例如:Integer会缓存[-128,127])。
- 其次,对于自定义类型,如果类型需要参与判等,那么务必同时实现 equals 和 hashCode方法,并确保逻辑一致。如果希望快速实现 equals、hashCode 方法,我们可以借助 IDE的代码生成功能,或使用 Lombok 来生成。如果类型也要参与比较,那么 compareTo 方法的逻辑同样需要和 equals、hashCode 方法一致。
- 最后,Lombok 的 @EqualsAndHashCode 注解实现 equals 和 hashCode 的时候,默认使用类型所有非 static、非 transient 的字段,且不考虑父类。如果希望改变这种默认行为,可以使用 @EqualsAndHashCode.Exclude 排除一些字段,并设置 callSuper = true来让子类的 equals 和 hashCode 调用父类的相应方法。
09丨数值计算:注意精度、舍入和溢出问题
- 务必不要使用Double作为金钱数值计算,因为浮点数计算会造成精度损失
- BigDecimal比较value,请使用compareTo
- 第一,切记,要精确表示浮点数应该使用 BigDecimal。并且,使用 BigDecimal 的Double 入参的构造方法同样存在精度丢失问题,应该使用 String 入参的构造方法或者BigDecimal.valueOf 方法来初始化
- 第二,对浮点数做精确计算,参与计算的各种数值应该始终使用 BigDecimal,所有的计算都要通过 BigDecimal 的方法进行,切勿只是让 BigDecimal 来走过场。任何一个环节出现精度损失,最后的计算结果可能都会出现误差
- 第三,对于浮点数的格式化,如果使用 String.format 的话,需要认识到它使用的是四舍五入,可以考虑使用 DecimalFormat 来明确指定舍入方式。但考虑到精度问题,我更建议使用 BigDecimal 来表示浮点数,并使用其 setScale 方法指定舍入的位数和方式
- 第四,进行数值运算时要小心溢出问题,虽然溢出后不会出现异常,但得到的计算结果是完全错误的。我们考虑使用 Math.xxxExact 方法来进行运算,在溢出时能抛出异常,更建议对于可能会出现溢出的大数运算使用 BigInteger 类
10丨集合类:坑满地的List列表操作
Arrays.asList 和 List.subList的使用
- Arrays.asList 得到的是 Arrays 的内部类 ArrayList,List.subList 得到的是 ArrayList 的
内部类 SubList,不能把这两个内部类转换为 ArrayList 使用。 - Arrays.asList 直接使用了原始数组,可以认为是共享“存储”,而且不支持增删元素;
List.subList 直接引用了原始的 List,也可以认为是共享“存储”,而且对原始 List 直接
进行结构性修改会导致 SubList 出现异常。 - 对 Arrays.asList 和 List.subList 容易忽略的是,新的 List 持有了原始数据的引用,可能
会导致原始数据也无法 GC 的问题,最终导致 OOM。
- Arrays.asList 得到的是 Arrays 的内部类 ArrayList,List.subList 得到的是 ArrayList 的
- Arrays.asList 不一定可以把所有数组转换为正确的 List。当传入基本类型数组的时候,List 的元素是数组本身,而不是数组中的元素
- 搜索超大 ArrayList 的时候遇到性能问题。我们考虑利用 HashMap 哈希表随机查找的时间复杂度为 O(1) 这个特性来优化性能,不过也要考虑 HashMap 存储空间上的代价,要平衡时间和空间
- 百分之九十的情况下,LinkedList在读写性能都没有ArrayList好
11丨空值处理:分不清楚的null和恼人的空指针
业务代码中 5 种最容易出现空指针异常的写法
- 参数值是 Integer 等包装类型,使用时因为自动拆箱出现了空指针异常;
- 字符串比较出现空指针异常;
- 诸如 ConcurrentHashMap 这样的容器不支持 Key 和 Value 为 null,强行 put null 的Key 或 Value 会出现空指针异常;
- A 对象包含了 B,在通过 A 对象的字段获得 B 之后,没有对字段判空就级联调用 B 的方法出现空指针异常;
- 方法或远程服务返回的 List 不是空而是 null,没有进行判空就直接调用 List 的方法出现空指针异常;
- 通过 Optional 配合 Stream 可以避免大多数冗长的 if-else 判空逻辑,实现一行代码优雅判空。另外,要定位和修复空指针异常,除了可以通过增加日志进行排查外,在生产上使用 Arthas 来查看方法的调用栈和入参会更快捷。
- POJO 中字段的 null 定位,从服务端的角度往往很难分清楚,到底是客户端希望忽略这个字段还是有意传了 null,因此我们尝试用 Optional类来区分 null 的定位。同时,为避免把空值更新到数据库中,可以实现动态 SQL,只更新必要的字段。
- 数据库字段允许保存 null,会进一步增加出错的可能性和复杂度。会有 NULL、空字符串和字符串 null 三种状态
MySQL sum 函数、count 函数,以及 NULL 值条件可能踩的坑
- sum 函数没统计到任何记录时,会返回 null 而不是 0,可以使用 IFNULL函数把 null 转换为 0;
- count 字段不统计 null 值,COUNT(*) 才是统计所有记录数量的正确方式。
- =NULL 并不是判断条件而是赋值,对 NULL 进行判断只能使用 IS NULL 或者 IS NOT NULL。
12丨异常处理:别让自己在出问题的时候变为瞎子
大多数业务应用都采用的三层架构:
业务性质上异常可能分为业务异常和系统异常两大类:
对于自定义的业务异常:以 Warn 级别的日志记录异常以及当前 URL、执行方法等信息后,提取异常中的错误码和消息等信息,转换为合适的 API 包装体返回给 API 调用方;
对于无法处理的系统异常:以 Error 级别的日志记录异常和上下文信息(比如 URL、参数、用户 ID)后,转换为普适的“服务器忙,请稍后再试”异常信息,同样以 API 包装体返回给调用方。
- Repository 层出现异常或许可以忽略,或许可以降级,或许需要转化为一个友好的异常。如果一律捕获异常仅记录日志,很可能业务逻辑已经出错,而用户和程序本身完全感知不到。
- Service 层往往涉及数据库事务,出现异常同样不适合捕获,否则事务无法自动回滚。此外 Service 层涉及业务逻辑,有些业务逻辑执行中遇到业务异常,可能需要在异常后转入分支业务流程。如果业务异常都被框架捕获了,业务功能就会不正常。
- 如果下层异常上升到 Controller 层还是无法处理的话,Controller 层往往会给予用户友好提示,或是根据每一个 API 的异常表返回指定的异常类型,同样无法对所有异常一视同仁。
错误打log姿势:
- 捕获了异常后直接生吞
没有生吞,但是丢弃异常的原始信息
@GetMapping("wrong1") public void wrong1(){ try { readFile(); } catch (IOException e) { //wrong1:原始异常信息丢失 //throw new RuntimeException("系统忙请稍后再试"); //wrong2:只保留了异常消息,栈没有记录 //log.error("文件读取错误, {}", e.getMessage()); //throw new RuntimeException("系统忙请稍后再试"); //correct1: log.error("文件读取错误", e); throw new RuntimeException("系统忙请稍后再试"); //correct2: throw new RuntimeException("系统忙请稍后再试", e); } }抛出异常时不指定任何消息
throw new RuntimeException();
如果你捕获了异常打算处理的话,除了通过日志正确记录异常原始信息外,通常还有
三种处理模式:- 转换,即转换新的异常抛出。对于新抛出的异常,最好具有特定的分类和明确的异常消息,而不是随便抛一个无关或没有任何信息的异常,并最好通过 cause 关联老异常。
- 重试,即重试之前的操作。比如远程调用服务端过载超时的情况,盲目重试会让问题更
严重,需要考虑当前情况是否适合重试。 - 恢复,即尝试进行降级处理,或使用默认值来替代原始数据。
小心 finally 中的异常
虽然 try 中的逻辑出现了异常,但却被 finally
中的异常覆盖了@GetMapping("wrong") public void wrong() { try { log.info("try"); //异常丢失 throw new RuntimeException("try"); } finally { // wrong //log.info("finally"); //throw new RuntimeException("finally"); //correct log.info("finally"); try { throw new RuntimeException("finally"); } catch (Exception ex) { log.error("finally", ex); } } }可以改为 try-with-resources 模式
@GetMapping("useresourceright") public void useresourceright() throws Exception { try (TestResource testResource = new TestResource()){ testResource.read(); } }
- 千万别把异常定义为静态变量,务必确保异常是每次 new 出来的。否则可能会引起栈信息的错乱。
确保正确处理了线程池中任务的异常:
- 如果任务通过 execute 提交,那么出现异常会导致线程退出,大量的异常会导致线程重复创建引起性能问题,我们应该尽可能确保任务不出异常,同时设置默认的未捕获异常处理程序来兜底;
- 如果任务通过 submit 提交意味着我们关心任务的执行结果,应该通过拿到的 Future 调用其 get 方法来获得任务运行结果和可能出现的异常,否则异常可能就被生吞了。
13丨日志:日志记录真没你想象的那么简单
记录日志引起的坑,容易出错主要在于三个方面:
- 日志框架的兼容问题
- 日志文件配置复杂且容易出错
日志记录本身就有些误区,比如没考虑到日志内容获取的代价、胡乱使用日志级别等
Java 体系的日志框架,确实非常多,而不同的类库,还可能选择使用不同的日志框架。这样一来,日志的统一管理就变得非常困难。为了解决这个问题,就有了 SLF4J((SimpleLogging Facade For Java))。而一般我们也都是使用SLF4J去管理:
SLF4J 实现了三种功能:
- 提供了统一的日志门面 API,即图中紫色部分,实现了中立的日志记录 API。
- 提供桥接功能,即图中蓝色部分,用来把各种日志框架的 API(图中绿色部分)桥接到SLF4J API。这样一来,即便你的程序中使用了各种日志 API 记录日志,最终都可以桥接到 SLF4J 门面 API。
- 提供适配功能,即图中红色部分,可以实现 SLF4J API 和实际日志框架(图中灰色部分)的绑定。SLF4J 只是日志标准,我们还是需要一个实际的日志框架。日志框架本身没有实现 SLF4J API,所以需要有一个前置转换。Logback 就是按照 SLF4J API 标准实现的,因此不需要绑定模块做转换。
常见问题:
- 如果程序启动时出现 SLF4J 的错误提示,那很可能是配置出现了问题,可以使用 Maven 的 dependency:tree 命令梳理依赖关系
- Logback 是 Java 最常用的日志框架,其配置比较复杂,你可以参考官方文档中关于Appender、Layout、Filter 的配置,切记不要随意从其他地方复制别人的配置,避免出现错误或与当前需求不符。
- 使用异步日志解决性能问题,是用空间换时间。但空间毕竟有限,当空间满了之后,我们要考虑是阻塞等待,还是丢弃日志。如果更希望不丢弃重要日志,那么选择阻塞等待;如果更希望程序不要因为日志记录而阻塞,那么就需要丢弃日志。
- 使用日志占位符,而不是 字符串拼接
14丨文件IO:实现高效正确的文件读写并非易事
如果需要读写字符流,那么需要确保文件中字符的字符集和字符流的字符集是一致
的,否则可能产生乱码。不指定Charset(程序会自动以当前机器的默认字符集来读取文件的)
char[] chars = new char[10]; String content = ""; try (FileReader fileReader = new FileReader("hello.txt")) { int count; while ((count = fileReader.read(chars)) != -1) { content += new String(chars, 0, count); } } log.info("result:{}", content);指定Charset
Files.write(Paths.get("hello2.txt"), "你好hi".getBytes(Charsets.UTF_8)); byte[] content = Files.readAllBytes(Paths.get("hello2.txt")); log.info("bytes:{}",Hex.encodeHexString(content));
使用 Files 类的一些流式处理操作,注意使用 try-with-resources 包装 Stream,确保底层文件资源可以释放,避免产生 too many open files 的问题。
不使用 try-with-resources(后台不会关闭进程,而是会一直新开一个进程,直到无法再开新的线程)
LongAdder longAdder = new LongAdder(); IntStream.rangeClosed(1, 1000000).forEach(i -> { try { Files.lines(Paths.get("demo.txt")).forEach(line -> longAdder.increment } catch (IOException e) { e.printStackTrace(); } }); log.info("total : {}", longAdder.longValue());使用 try-with-resources
LongAdder longAdder = new LongAdder(); IntStream.rangeClosed(1, 1000000).forEach(i -> { try (Stream<String> lines = Files.lines(Paths.get("demo.txt"))) { lines.forEach(line -> longAdder.increment()); } catch (IOException e) { e.printStackTrace(); } }); log.info("total : {}", longAdder.longValue());
进行文件字节流操作的时候,一般情况下不考虑进行逐字节操作,使用缓冲区进行批量读写减少 IO 次数,性能会好很多。一般可以考虑直接使用缓冲输入输出流BufferedXXXStream,追求极限性能的话可以考虑使用 FileChannel 进行流转发。
不对数据进行处理,直接把原文件数据写入目标文件;
private static void perByteOperation() throws IOException { try (FileInputStream fileInputStream = new FileInputStream("src.txt"); FileOutputStream fileOutputStream = new FileOutputStream("dest.txt")) int i; while ((i = fileInputStream.read()) != -1) { fileOutputStream.write(i); } } }复制一个 35MB 的文件,耗时 190 秒
改良后,使用 100 字节作为缓冲区
private static void bufferOperationWith100Buffer() throws IOException { try (FileInputStream fileInputStream = new FileInputStream("src.txt"); FileOutputStream fileOutputStream = new FileOutputStream("dest.txt")) byte[] buffer = new byte[100]; int len = 0; while ((len = fileInputStream.read(buffer)) != -1) { fileOutputStream.write(buffer, 0, len); } } }复制一个 35MB 的文件,耗时 26秒
(可以看到,在进行文件 IO 处理的时候,使用合适的缓冲区可以明显提高性能)使用BufferedXXXStream,其内部实现了一个默认 8KB 大小的缓冲区(但是,在使用BufferedInputStream 和 BufferedOutputStream 时,还是建议大家再使用一个缓冲进行读写,不要因为它们实现了内部缓冲就进行逐字节的操作)。
这里我直接贴出三种方式,具体代码 放在code repository里,可自行翻阅:
Java业务开发常见错误100例- 直接使用 BufferedInputStream 和 BufferedOutputStream;
- 额外使用一个 8KB 缓冲,使用 BufferedInputStream 和 BufferedOutputStream;
- 直接使用 FileInputStream 和 FileOutputStream,再使用一个 8KB 的缓冲。
最后,三者的性能分别是 1.4、110 毫秒 和 110 毫秒 - 使用FileChannel,速度最快,可达 50 毫秒,比最原始的190秒,快了足足 数千倍
最后要强调一点的是,文件操作因为涉及操作系统和文件系统的实现,JDK 并不能确保所有IO API 在所有平台的逻辑一致性,代码迁移到新的操作系统(比如上到测试场或者生产场)或文件系统时,要重新进行功能测试和性能测试。
15丨序列化:一来一回你还是原来的你吗?
基于Redis 和 Web API 的入参和出参两个场景,介绍 序列化和反序列化时需要避开的几个坑
- 要确保序列化和反序列化算法的一致性。因为,不同序列化算法输出必定不同,要正确处理序列化后的数据就要使用相同的反序列化算法。
- Jackson 有大量的序列化和反序列化特性,可以用来微调序列化和反序列化的细节。需要注意的是,如果自定义 ObjectMapper 的 Bean,小心不要和 Spring Boot 自动配置的 Bean 冲突。
- 在调试序列化反序列化问题时,我们一定要捋清楚三点:是哪个组件在做序列化反序列化、整个过程有几次序列化反序列化,以及目前到底是序列化还是反序列化
- 对于反序列化默认情况下,框架调用的是无参构造方法,如果要调用自定义的有参构造方法,那么需要告知框架如何调用。更合理的方式是,对于需要序列化的 POJO 考虑尽量不要自定义构造方法。
- 枚举不建议定义在 DTO 中跨服务传输,因为会有版本问题,并且涉及序列化反序列化时会很复杂,容易出错。因此,只建议在程序内部使用枚举。
16 | 用好Java 8的日期时间类,少踩一些“老三样”的坑
Java Date系列已成为遗留产品,新的Java8中的时间新特性,已经可以全面替换旧的了,旧的不仅可读性差、易用性差、使用起来冗余繁琐,还有线程安全问题,所以也强烈建议大家使用JDK8的。除了好用之外,二者有区别的地方还在于:
java.util.Date 类是因为使用 UTC 表示,所以没有时区概念,本质是时间戳;
而 LocalDateTime,严格上可以认为是一个日期时间的表示,而不是一时间点。
初始化时间: (例子:2019 年 12 月 31 日 11 点 12 分 13秒)
jdk8之前:
Date date = new Date(2019 - 1900 , 11, 31, 11, 12, 13);有国际化需求,需要使用到Calendar类jdk8之前: 年应该是和 1900 的差值,月应该是从0 到 11 而不是从 1 到 12。
- jdk8后:
LocalDateTime date = LocalDateTime.of(2019, 12, 31, 11, 12, 13);
时区问题:处理好时间和时区问题首先就是要正确保存日期时间。这里有两种保存方式:
- 以 UTC 保存,保存的时间没有时区属性,是不涉及时区时间差问题的世界统一
时间,前面我们说过,Date类就是存得是UTC的时间戳, - 以字面量保存,比如年 / 月 / 日 时: 分: 秒,一定要同时保存时区信息。
时区因素会带来两个问题:
Date存得是UTC时间戳,不同时区服务器读出的时间是不一样的,例如:拿 2020-01-02 22:00:00,这个时间作为例子,分别按照默认程序时区,和指定NewYork时区,输出 解析后的时间
String stringDate = "2020-01-02 22:00:00"; SimpleDateFormat inputFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"); Date date1 = inputFormat.parse(stringDate); System.out.println(date1 + ":" + date1.getTime()); inputFormat.setTimeZone(TimeZone.getTimeZone("America/New_York")); Date date2 = inputFormat.parse(stringDate); System.out.println(date2 + ":" + date2.getTime());输出:(发现相差13个小时,所以说,如果你的公司服务器有跨时区的,)
Thu Jan 02 22:00:00 CST 2020:1577973600000
Fri Jan 03 11:00:00 CST 2020:1578020400000解决方案:务必指定 存和读的 时区是一致的。存的时候,需要使用正确的当前时区来保存,这样 UTC 时间才会正确;读的时候,也只有正确设置本地时区,才能把 UTC 时间转换为正确的当地时间。
更好的方案:Java 8 推出了新的时间日期类 ZoneId、ZoneOffset、LocalDateTime、ZonedDateTime
和 DateTimeFormatter,处理时区问题更简单清晰。LocalDateTime 不带有时区属性,所以命名为本地时区的日期时间;
而 ZonedDateTime=LocalDateTime+ZoneId,具有时区属性。
因此,LocalDateTime 只能认为是一个时间表示,ZonedDateTime 才是一个有效的时间我们拿上海、纽约和东京,举个例子: 依旧是 2020-01-02 22:00:00这个time
代码:
String stringDate = "2020-01-02 22:00:00"; ZoneId timeZoneSH = ZoneId.of("Asia/Shanghai"); ZoneId timeZoneNY = ZoneId.of("America/New_York"); ZoneId timeZoneJST = ZoneOffset.ofHours(9); DateTimeFormatter dateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); ZonedDateTime date = ZonedDateTime.of(LocalDateTime.parse(stringDate, dateTimeFormatter), timeZoneJST); DateTimeFormatter outputFormat = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss Z"); System.out.println(timeZoneSH.getId() + outputFormat.withZone(timeZoneSH).format(date)); System.out.println(timeZoneNY.getId() + outputFormat.withZone(timeZoneNY).format(date)); System.out.println(timeZoneJST.getId() + outputFormat.withZone(timeZoneJST).format(date));输出:
Asia/Shanghai2020-01-02 21:00:00 +0800
America/New_York2020-01-02 08:00:00 -0500
+09:002020-01-02 22:00:00 +0900- 结论:要正确处理国际化时间问题,推荐使用 Java 8 的日期时间类,即使用 ZonedDateTime 保存时间,然后使用设置了 ZoneId 的 DateTimeFormatter 配合ZonedDateTime 进行时间格式化得到本地时间表示。这样的划分十分清晰、细化,也不容易出错。
- 以 UTC 保存,保存的时间没有时区属性,是不涉及时区时间差问题的世界统一
日期时间格式化和解析
Date - ”YYYY-MM-dd 著名Bug“--提前跨年,”这明明是一个 2019 年的日期,怎么使用 SimpleDateFormat 格式化后就提前跨年了“。
- 例如:初始化一个 Calendar,设置日期时间为 2019 年 12 月 29 日,使用大写的 YYYY 来初始化 SimpleDateFormat。但最后输出的,却是 2020 年 12 月 29 日,好家伙,直接多了一年!
- 其原因在于:开发人员混淆了 SimpleDateFormat 的各种格式化模式。JDK的文档中有说明:小写 y 是年,而大写 Y 是 week year,也就是所在的周属于哪一年。
- 而按照当前 zh_CN 区域来说,2020 年第一周的条件是,从周日开始的完整 7 天,2020 年包含 1 天即可。显然,2019 年 12 月 29 日周日到 2020 年 1 月 4 日周六是 2020 年第一周,得出的 weekyear 就是 2020 年。但是如果你把时区换成France,就不会有问题。
- Date - 定义的 static 的 SimpleDateFormat 可能会出现线程安全问题
Date - 当需要解析的字符串和格式不匹配的时候,SimpleDateFormat 表现得很宽容,例如:
使用 yyyyMM 来解析 20160901,它居然不报错,但是结果是:
2091 年 1 月 1 日,原因在于:把0901 当成了月份,相当于 75 年,无语子。。。- 相比旧的Date,新的JDK8 Date就没有这些问题,也不用管是 YYYY 还是 yyyy,DateTimeFormatter要是线程安全的。
日期时间的计算:
日期时间的计算,一个很多开发常踩的坑。有人直接使用时间戳进行时间计算,比如希望得到当前时间之后 30 天的时间,会这么写代码:直接把 newDate().getTime 方法得到的时间戳加 30 天对应的毫秒数,也就是 30 天 1000 毫秒 3600 秒 *24 小时。但是会发现 结果根本不对
其原因在于 int发生溢出,修复方式就是把 30 改为 30L
但还是很繁琐,且容易出错,所以jdk8之前,更推荐使用 Calendarjdk8后,日期时间类型,可以直接进行各种计算,更加简洁、方便和强大。
但 计算两个日期差时可能会踩坑,
Period.between 得到了两个 LocalDate 的差,返回的是两个日期差几年零几月零几天。如果希望得知两个日期之间差几天,直接调用Period 的 getDays() 方法得到的只是最后的“零几天”,而不是算总的间隔天数。
17丨别以为“自动挡”就不可能出现OOM
通常而言,Java 程序的 OOM有如下几种可能:
- 程序确实需要超出 JVM 配置的内存上限的内存。不管是程序实现的不合理,还是因为各种框架对数据的重复处理、加工和转换,相同的数据在内存中不一定只占用一份空间。针对内存量使用超大的业务逻辑,比如缓存逻辑、文件上传下载和导出逻辑,我们在做容量评估时,可能还需要实际做一下 Dump,而不是进行简单的假设。
- 出现内存泄露,其实就是我们认为没有用的对象最终会被 GC,但却没有。GC 并不会回收强引用对象,我们可能经常在程序中定义一些容器作为缓存,但如果容器中的数据无限增长,要特别小心最终会导致 OOM。使用 WeakHashMap 是解决这个问题的好办法,但值得注意的是,如果强引用的 Value 有引用 Key,也无法回收 Entry。
- 不合理的资源需求配置,在业务量小的时候可能不会出现问题,但业务量一大可能很快就会撑爆内存。比如,随意配置 Tomcat 的 max-http-header-size 参数,会导致一个请求使用过多的内存,请求量大的时候出现 OOM。在进行参数配置的时候,我们要认识到,很多限制类参数限制的是背后资源的使用,资源始终是有限的,需要根据实际需求来合理设置参数。
最后想说的是,在出现 OOM 之后,也不用过于紧张。我们可以根据错误日志中的异常信息,再结合 jstat 等命令行工具观察内存使用情况,以及程序的 GC 日志,来大致定位出现 OOM 的内存区块和类型。其实,我们遇到的 90% 的 OOM 都是堆 OOM,对 JVM 进程进行堆内存 Dump,或使用 jmap 命令分析对象内存占用排行,一般都可以很容易定位到问题。
18丨当反射、注解和泛型遇到OOP时,会有哪些坑?
虽然我们日常业务项目中几乎都是增删改查,用到反射、注解和泛型这些高级特性的机会少之又少,没啥好学的。但是,只有学好、用好这些高级特性,才能开发出更简洁易读的代码,而且几乎所有的框架都使用了这三大高级特性。比如,要减少重复代码,就得用到反射和注解。
- 反射调用方法并不是通过调用时的传参确定方法重载,而是在获取方法的时候通过方法名和参数类型来确定的。遇到方法有包装类型和基本类型重载的时候,所以需要特别注意这一点。
- 反射获取类成员,需要注意 getXXX 和 getDeclaredXXX 方法的区别,其中 XXX 包括 Methods、Fields、Constructors、Annotations。
- 泛型因为类型擦除会导致泛型方法 T 占位符被替换为 Object,子类如果使用具体类型覆盖父类实现,编译器会生成桥接方法。这样既满足子类方法重写父类方法的定义,又满足子类实现的方法有具体的类型。使用反射来获取方法清单时,所以需要特别注意这一点。
- 自定义注解可以通过标记元注解 @Inherited 实现注解的继承,不过这只适用于类。如果要继承定义在接口或方法上的注解,可以使用 Spring 的工具类AnnotatedElementUtils,并注意各种 getXXX 方法和 findXXX 方法的区别。
19丨Spring框架:IoC和AOP是扩展的核心
- 让 Spring 容器管理对象,要考虑对象默认的 Scope 单例是否适合,对于有状态的类型,单例可能产生内存泄露问题。
- 如果要为单例的 Bean 注入 Prototype 的 Bean,绝不是仅仅修改 Scope 属性这么简单。由于单例的 Bean 在容器启动时就会完成一次性初始化。最简单的解决方案是,把Prototype 的 Bean 设置为通过代理注入,也就是设置 proxyMode 属性为TARGET_CLASS。
- 如果一组相同类型的 Bean 是有顺序的,需要明确使用 @Order 注解来设置顺序。可以再回顾下,两个不同优先级切面中 @Before、@After 和 @Around 三种增强的执行顺序,是什么样的。
20丨Spring框架:框架帮我们做了很多工作也带来了复杂度
- Spring Cloud 会使用Spring Boot 的特性,根据当前引入包的情况做各种自动装配。如果我们要扩展 Spring 的组件,那么只有清晰了解 Spring 自动装配的运作方式,才能鉴别运行时对象在 Spring 容器中的情况,不能想当然认为代码中能看到的所有 Spring 的类都是 Bean。
- 对于配置优先级的案例,分析配置源优先级时,如果我们以为看到PropertySourcesPropertyResolver 就看到了真相,后续进行扩展开发时就可能会踩坑。我们一定要注意,分析 Spring 源码时,你看到的表象不一定是实际运行时的情况,还需要借助日志或调试工具来理清整个过程。如果没有调试工具,可以借助Arthas,来分析代码调用路径。