Java开发避坑指南 - 常见易错点总结
本文是对极客时间专栏:《Java业务开发常见错误100例》学习后,进行的总结,包括了Java开发各种场景会遇到的问题及注意点。
并发工具
- 线程重用(线程池)导致的ThreadLocal出现脏数据
- 显式地清空设置的数据
- 并发工具的特性
- ConcurrentHashMap 只能保证提供的原子性读写操作是线程安全的
- 诸如size、isEmpty和containsValue等聚合方法,在并发情况下可能会反映中间状态
- putAll 这样的聚合方式法也不能确保原子性
- 提供了一些原子性的复合逻辑方法:比如computeIfAbsent
- ConcurrentHashMap 只能保证提供的原子性读写操作是线程安全的
- 了解适用场景样
- CopyOnWriteArrayList在读写均衡或者大量写操作的场景下,会导致性能问题,甚至比直接同步还慢
锁
- 了解操作是否是原子的
- a < b 这种比较操作在字节码层面是加载 a、加载 b 和比较三步
- 加锁前要清楚一点锁和被保护的对象是不是一个层面的
- 比如静态字段只有类级别的锁才能保护
- 加锁要考虑锁的粒度和场景问题
- 尽可能降低锁的粒度
- 区分读写场景及资源的访问冲突,考虑悲观锁还是乐观锁
- 多个锁要小心死锁
- 保证获取锁的顺序
线程池
- 手动声名创建线程池
- newFixedThreadPool() OOM风险
- 默认构造方法的LinkedBlockingQueue长度是Integer.MAX_VALUE
- 虽然工作线程数量固定,但任务队列是无界的,任务多且执行较慢会造成积压乃至OOM
- newCachedThreadPool() OOM
- 最大线程数是Integer.MAX_VALUE
- newFixedThreadPool() OOM风险
- 用一些监控手段来观察线程池的状态
- 比如定时输出线程池的线程数、活跃线程数、完成任务数、积压任务等
- 了解线程池行为
- 不会初始化 corePoolSize 个线程,有任务来了才创建工作线程;
- 当核心线程满了之后不会立即扩容线程池,而是把任务堆积到工作队列中;
- 当工作队列满了后扩容线程池,一直到线程个数达到 maximumPoolSize 为止;
- 如果队列已满且达到了最大线程后还有任务进来,按照拒绝策略处理;
- 当线程数大于核心线程数时,线程等待 keepAliveTime 后还是没有任务需要处理的话,收缩线程到核心线程数。
- 确认清楚线程池本身是不是复用的
- 避免创建过多线程池
- 根据任务的性质来选用不同的线程池
连接池
- 注意鉴别客户端是否基于连接池
- 连接池和连接分离的API:有一个XxxPool类负责连接池实现,从其获得连接XxxConnection进行请求
- 内部带有连接池的API:提供一个XxxClient,通过这个类进行请求,这个类内部维护连接池
- 非连接池的API:一般命名为XxxConnection
- 使用连接池务必确保复用
- 比如可以把 CloseableHttpClient声明为static
- 合理配置连接池
- 持续监控,动态调整
HTTP调用
- ConnectTimeout
- 连接超时配置过长:一般TCP连接通常在毫秒级,如果很久连不上很可能是网络或防火墙的问题,超时设太长没什么意义
- 排查连接超时问题时没理清连的是哪里:可能是服务端的问题,也可能是Nginx的问题
- ReadTimeout
- 读取超时,服务端的执行不会中断
- 读取超时指的是,向Socket 写入数据后,我们等到Socket返回数据的超时时间
- 超时时间配置过长会让自身被下游服务拖慢,过短又可能影响成功率
- Feign和Ribbon配合
- Feign默认readTimeout 和 connectTimeout为1s
- 如果要配置Feign的readTimeout,就必须同时配置connectTimeout才能生效
- 同时配置Feign和Ribbon 的超时,以Feign为准(注意Ribbon的配置参数与Feign不一样)
- Ribbon默认get请求失败时自动重试1次
- 修改接口请求方法
- 设置ribbon.MaxAutoRetriesNextServer = 0(默认1)
- Http请求限制了并发
- 包括HttpClient 在内的HTTP客户端以及浏览器,都会限制客户端调用的最大并发数
- HttpClient默认(PoolingHttpClientConnectionManager):
- defaultMaxPerRoute=2(同主机/域名最大并发数)
- maxTotal=20(整体最大并发)
- HttpClient默认(PoolingHttpClientConnectionManager):
- 包括HttpClient 在内的HTTP客户端以及浏览器,都会限制客户端调用的最大并发数
Spring声明式事务
- @Transactional未生效
- 除非特殊配置(比如使用 AspectJ实现AOP),否则只有定义在public方法上才能生效
- 必须通过代理过的类从外部调用目标方法才能生效
- 事务即便生效也不一定能回滚
- 只有异常传播出了标记了@Transactional注解的方法,事务才能回滚
- 默认情况下,出现RuntimeException或Error的时候,Spring 才会回滚事务
- 使用@Transactional(rollbackFor = Exception.class)
- 不出异常,事务也不一定可以提交
- 即使父方法在调用子方法时catch了异常,但两个方法还是共用一个事务,子逻辑标记了事务需要回滚,主逻辑也不能提交
- 设置事务传播策略:REQUIRES_NEW
- 确认事务传播配置是否符合自己的业务逻辑
- 即使父方法在调用子方法时catch了异常,但两个方法还是共用一个事务,子逻辑标记了事务需要回滚,主逻辑也不能提交
数据库索引
- InnoDB存储数据
- 数据被分成若干页,以页为单位保存在磁盘中,页大小一般是 16KB
- 各个数据页组成一个双向链表,每个页中的记录按主键顺序组成单向链表;每个页中有一个页目录,方便按照主键查询记录
- 聚簇索引和二级索引(均利用B+树结构)
- 聚簇索引:InnoDB自动使用主键作为索引键,叶子节点保存实际数据
- 二级索引(非聚簇索引、辅助索引):以某字段作为索引键,但叶子节点中保存的是主键而不是实际数据
- 回表:获取主键后,再去聚簇索引查找数据行
- 索引失效
- 索引只能匹配列前缀:比如对创建了索引的字段name,进行like ‘%name’ 查询是走全表扫描
- 条件涉及函数操作无法走索引:比如搜索条件用length函数
- 联合索引只能匹配左边的列:在联合索引的情况下,数据是按照索引第一列排序,第一列数据相同时才会按照第二列排序,仅按照第二列搜索无法走索引
- 数据库基于成本决定是否走索引
- MySQL 选择索引,并不是按照 WHERE 条件中列的顺序进行的
- 即便列有索引,甚至有多个可能的索引方案,MySQL也可能不走索引
- 可通过EXPLAIN和optimizer_trace对执行计划进行分析
判等问题
- 注意 equals 和 == 的区别
- 基本类型只能用==,比较的是直接值
- 引用类型需要用equals判等,因为引用类型的直接值是指针,也就是两个对象在内存中的地址
- Integer内部缓存
- Integer a = 127 会被编译器转换为 Integer.valueOf(127),valueOf()做了缓存,默认范围是[-128,127]
- Integer.IntegerCache
- 通过 -XX:AutoBoxCacheMax=N 设置缓存最大值范围
- Integer a = 127 会被编译器转换为 Integer.valueOf(127),valueOf()做了缓存,默认范围是[-128,127]
- 字符串常量池
- 通过双引号声明字符串对象时,JVM会先检查并放入字符串常量池,再返回引用
- 使用String.intern()也会走常量池机制
- 字符串常量池是一个固定容量的 Map,可使用XX:StringTableSize=N指定桶的数量
- 如果用intern()要注意控制驻留的字符串的数量
- 注意equals、hashCode、compareTo的逻辑一致性
数值计算
- 浮点数精度问题
- Java采用了IEEE 754标准实现浮点数的表达和运算,double和flout无法精确表达浮点数
- 使用BigDecimal时,务必使用字符串构造方法
- 浮点数舍入和格式化方式
- String.format采用四舍五入
- 浮点数的字符串格式化也要通过 BigDecimal 进行
- BigDecimal的equals方法
- BigDecimal的equals不仅比较值,还判断了scale(位数)
- 如果只希望比较value使用compareTo()
- 数值溢出:所有的基本数值类型都有超出表达范围的可能性
集合类
- Arrays.asList()的三个坑
- 不能直接使用 Arrays.asList()来转换基本类型数组:
- 最后转化的List只包含一个数组元素
- Arrays.asList()接收的是一个泛型T参数,传入的数组被当做一个整体成为T
- 因为int可以装箱为Integer,但int[]不能装箱为Integer[]
- Arrays.asList 返回的 List 不支持增删操作:
- 返回的List不是java.util.ArrayList,而是Arrays的内部类ArrayList,没有覆写add方法
- 对原始数组的修改会影响到我们获得的那个List:
- Arrays.ArrayList 其实是直接使用了原始的数组
- 不能直接使用 Arrays.asList()来转换基本类型数组:
- List.subList()进行切片操作可能导致 OOM
- subList()获得的其实是内部类SubList
- subList()后返回的子List会强引用原始List,可以认为SubList是原始List的视图,并不是独立的List
- 避免相互影响的方式:
- 将subList()返回的子List传入ArrayList构造器,重新构建并使用独立的ArrayList
- 使用Stream的skip()和limit()来达到切片的目的
- 使用合适的数据结构做合适的事
- 考虑时间、空间成本的平衡
- 不要过分迷信大O时间复杂度
- LinkedList的插入虽然是O(1),但是往特定位置插入时,需要先拿到该位置节点对象,因此遍历到此对象需要时间,而且大部分情况下LinkedList不管是查询还是插入都会慢于ArrayList
空值处理
- Integer判空,可以使用Optional.ofNullable()、orElse()来操作
- String和字面量比较,可以将字面量放前面
- 明确POJO中null属性的含义
- 数据库字段尽量都设置默认值,不允许NULL
- MySQL有关 NULL 的坑
- sum 函数没统计到任何记录时,会返回 null 而不是 0
- count(字段)不统计null值,count(*)才统计所有记录数量
异常处理
- 捕获和处理异常容易犯的错
- 在业务代码层面考虑异常处理,而不是粗犷捕获和处理异常
- 捕获了异常后不要生吞,记录异常信息
- 不要丢弃异常的原始信息
- 抛出异常时使用具有意义的异常类型和异常消息
- 小心finally中的异常
- try中的逻辑出现的异常会被finally中的异常覆盖
- 解决:
- finally代码块自己负责异常捕获和处理
- 使用addSuppressed()把finally中的异常附加到主异常上
- 不要把异常定义为静态变量、会导致异常栈信息错乱
- 提交线程池的任务中的异常
- 由于异常的抛出,此任务线程退出,线程池会重新创建一个线程
- 处理
- 以execute()提交到线程池的异步任务,最好在任务内部做好异常处理
- 设置自定义的异常处理程序作为保底
- 声明线程池时自定义线程池的未捕获异常处理程序:setUncaughtExceptionHandler()
- 设置全局的默认未捕获异常处理程序:setDefaultUncaughtExceptionHandler()
日志
- 日志体系
- 不能同时使用某个日志实现的桥接和适配,不然会产生死循环
- 比如log4j-over-slf4j 和 slf4j-log4j12
- 不能同时使用某个日志实现的桥接和适配,不然会产生死循环
- 日志输出重复
- logger配置继承关系导致重复:默认情况下子Logger会继承root的Logger的appender
- 需要设置additivity=false来避免继承appender
- 错误配置LevelFilter:LevelFilter仅配置level无法真正起作用
- 需要配置onMatch和onMismatch属性,ACCEPT、DENY、NEUTRAL(由下一个filter处理)
- 使用ThresholdFilter,可以直接拒绝未匹配的级别
- logger配置继承关系导致重复:默认情况下子Logger会继承root的Logger的appender
- 使用异步日志的坑
- AsyncAppender配置参数
- queueSize用于控制阻塞队列大小,默认 256,即内存中最多保存 256 条日志
- discardingThreshold 是控制丢弃日志的阈值,主要是防止队列满后阻塞。默认情况下,队列剩余量低于队列长度的 20%,就会丢弃 TRACE、DEBUG 和 INFO 级别的日志
- neverBlock 用于控制队列满的时候,加入的数据是否直接丢弃,不会阻塞等待,默认 false
- 记录异步日志撑爆内存:queueSize 设置得特别大
- 记录异步日志出现日志丢失
- 记录异步日志出现阻塞:neverBlock 默认为 false,意味着总可能会出现阻塞
- AsyncAppender配置参数
文件IO
- 文件读写需要确保字符编码一致
- 使用 Files类的一些流式处理操作注意释放文件句柄
- File.lines()返回的是Stream<String>,需要手动close来释放资源
- 注意读写文件要考虑设置缓冲区
- 使用BufferedXXXStream或FileChannel
序列化
- 序列化和反序列化需要确保算法一致
- RedisTemplate和StringRedisTemplate存取数据无法相互通用
- 注意 Jackson JSON 反序列化对额外字段的处理
- SpringBoot自动创建的ObjectMapper设置FAIL_ON_UNKNOWN_PROPERTIES=false,以确保反序列化出现未知字段时不要抛出异常
- 自定义ObjectMapper会替代SpringBoot默认的ObjectMapper
- 反序列化时要小心类的构造方法
- 默认情况下,Jackson反序列化只会调用无参构造方法创建对象
- 枚举作为 API 接口参数或返回值的坑
- 客户端和服务端的枚举定义不一致时,会出异常
- 比如服务端新增了一个枚举值
- 可通过Jackson的相关特性设置枚举值未知时的默认值
- 枚举序列化反序列化实现自定义的字段非常麻烦,会涉及Jackson的Bug
- 枚举默认序列化为其字面值字符串
- 客户端和服务端的枚举定义不一致时,会出异常
Java 8的日期时间类
- 时区问题
- Date类并无时区问题,只保存时间戳,代表Epoch时间到现在的毫秒数
- Calendar 是有时区概念的
- SimpleDateFormat的坑
- 格式化表达式,年份用y而不是Y
- SimpleDateFormat解析和格式化操作是非线程安全的,只能在同一线程复用
- SimpleDateFormat不会强制校验字符串和格式是否匹配
- 比如用yyyyMM解析20160901会得到2091年1月1日,因为0901被当做901个月
- 使用java8的DateTimeFormatter
- 线程安全、解析严格
OOM
- 重复对象太多,重复构建DTO对象
- 使用 WeakHashMap 不等于不会 OOM
- WeakHashMap逻辑:
- put一个对象进Map时,它的key会被封装成弱引用对象
- 发生GC时,弱引用的key被发现并放入queue
- 调用get等方法时,扫描queue删除key,以及包含key和value的Entry对象
- 如果Value持有Key的引用(强),则Key最终无法回收
- WeakHashMap逻辑:
- Tomcat 参数配置不合理导致
- 类似server.max-http-header-size等配置,一定要根据实际需求来修改,不要设置一个超大的参数,避免并发量大时,因为资源大量分配导致OOM
反射、注解和泛型
- 反射调用方法不是以传参决定重载
- 反射所调用的方法是获取方法时通过传入的方法名称和参数类型来确定
- Integer.TYPE代表int类型
- 重写泛型方法时,要避免泛型擦除后的重写失败
- 继承时要指定泛型,否则会多出一个参数为Object的方法
- 注意getMethods()和getDeclaredMethods()的区别
- 注解的继承
- 子类以及子类的方法,无法自动继承父类和父类方法上的注解
- @Inherited能实现类注解的继承
- Spring的AnnotatedElementUtils可以找到父类和父类方法上的注解
Spring框架
- 在为类标记上@Service注解把类型交由容器管理前,首先评估一下类是否有状态,然后为Bean设置合适的 Scope
- Prototype Bean注入其他单例Bean,也只会创建一次,无法实现多例
- 通过代理注入:
- @Scope(value = “prototype”, proxyMode = ScopedProxyMode.TARGET_CLASS)
- 每次从ApplicationContext获取Bean
- 通过代理注入:
- 监控切面因为顺序问题导致 Spring 事务失效
- 自定义的切面将Exception处理了,Spring事务处理器无法捕获异常
- 将自定义切面的优先级设置更高(更外层,先进后出)
- 自定义的切面将Exception处理了,Spring事务处理器无法捕获异常
- AOP切不到FeignClient
- FeignClient创建过程:当URL没有内容时在内部通过FeignContext从容器获取实例,是一个Bean,可以被切到;但指定了URL时,会new ApacheHttpClient(),不是Spring Bean,不能被切到
- Spring 程序配置的优先级问题