Java开发坑点解析-代码篇- 读书笔记

31 阅读15分钟

chap2.5:HTTP调用的超时、重试、并发问题

1-超时时间设置

  • 连接超时 ConnectTimeout:建立连接阶段的最大等待时长
  • 读取超时 ReadTimeout:读取套接字数据的最大等待时长(服务端请求处理时长)

配置Feign的超时时间,需要同时配置两个参数

feign.client.config.default.readTimeout=3000
feign.client.config.default.connectTimeout=3000

Feign的超时时间配置优先级 > Ribbon的超时时间配置

2-Ribbon的自动请求重试

Ribbon在处理GET请求时默认最大重试次数为1,GET请求超时时自动重试一次(可能导致如短信重复发送的问题)

可通过设置 ribbon.MaxAutoRetriesNextServer=0 属性解决

3-HttpClient的默认最大请求数限制

使用 PoolingHttpClientConnectionManager 构造的HttpClient,默认 defaultMaxPerRoute=2maxTotal=20,即

  • 单一域名最大并发请求数为2
  • 所有主机的最大并发连接数量为20

Feign中默认单一域名并发连接数为50,所有连接数为200

4-Java开发中的容量限制问题

  • 操作系统/物理机/容器限制:带宽、文件句柄、内存、CPU等
  • Web容器:最大连接、最大线程数
  • 数据库连接池:最大连接数
  • HttpClient:单一域名最大连接数、所有域名并发连接数
  • 业务线程池

chap2.6:Spring声明式事务处理

排查事务处理时可能需要开启debug日志,如配置logging.level.org.springframework.orm.jpa=DEBUG为JPA开启debug日志

1-导致事务不生效的几种常见原因

  • 在private方法上使用@Transactional声明事务

不生效原因:声明式事务通过切面在运行时注入代理类(通过CGLib实现动态织入),事务执行时通过调用代理类中的方法捕获异常进行事务回滚。调用 this.privateMethod 时调用的不是代理类的方法,所以事务不生效

解决方法:将private方法改为public,并且不要通过this.method调用自己;或使用AspectJ配置在编译时就静态织入切面(需要解决lombok的问题)

  • 没有正确抛出异常

不生效原因:在@Transactional声明的事务方法中捕获异常但没有再次抛出,导致代理类感知不到异常发生。代理类默认只针对 RuntimeException 和 Error 类型的异常进行回滚,不会对受检异常(如IOException)进行回滚

2-事务传播

主子任务中如果需要控制异常回滚,需要设置@Transactional注解中的propagation属性

3-编程式事务控制

使用 TransactionTemplate

chap2.7:数据库索引

1-数据存储结构

  • InnoDB引擎采用页而不是行的粒度来保存数据

InnoDB数据页结构.png

  • InnoDB会自动使用主键作为聚簇索引的索引键。聚簇索引的特点是索引和数据行物理存储顺序完全一致,叶子节点直接存储数据行本身,而不是指向数据行的指针

聚簇索引.png

2-索引失效的情况

  • 索引只能匹配列前缀(like name% 可以走索引,like %name不行)
  • 条件涉及函数操作无法走索引(对索引进行运算)
  • 联合索引只能匹配左边的列。(name和score建了联合索引,仅按score查询无法走索引)

3-数据库基于成本决定是否走索引

  • IO成本,是从磁盘把数据加载到内存的成本。默认情况下,读取数据页的IO成本常数是1(也就是读取1个页成本是1)
  • CPU成本,是检测数据是否满足条件和排序等CPU操作的成本。默认情况下,检测记录的成本是0.2
  • MySQL 5.6及之后的版本中,可以使用optimizer trace功能查看优化器生成执行计划的整个过程
SET optimizer_trace="enabled=on";
SELECT * FROM person WHERE NAME >'name84059' AND create_time>'2020-01-24 05:00:00';
SELECT * FROM information_schema.OPTIMIZER_TRACE;
SET optimizer_trace="enabled=off";

chap2.8:判等问题

1-equals 和 ==

  • 对象使用 equals 判等,基础类型使用 == 判等
  • Integer 等包装类型存在缓存(享元模式),String 类型存在字符串常量池
  • String字面量会存储在字符串常量池(StringTable)中,字符串常量池是一个哈希表,其bucket数量可能会影响字符串创建的效率。可通过 -XX:+PrintStringTableStatistic 参数设置bucket大小

2-equals, hashCode 和 compareTo 需要配套实现

  • equals 和 hashCode 方法的默认关系可见 Objects 类中的方法注释
  • Objects.hash() 方法可快速重写 hashCode 方法
  • compareTo 的语义需要与 equals 和 hashCode 保持一致

3-Lombok

  • @Data 注解包含了@EqualsAndHashCode注解,默认会实现equals和hashCode方法
  • 子类默认不会继承父类中通过@Data 注解自动生成的equals和hashCode方法
  • @EqualsAndHashCode 注解默认会为所有非static、非transient的字段生成hashcode和equals,且不考虑父类属性
  • @EqualsAndHashCode(callSuper = true) 设置callSuper属性为true可以包含父类属性(调用父类的equals和hashCode)

chap2.9:精度问题

  • BigDecimal中,scale表示小数点右边的位数,而precision表示精度,也就是有效数字的长度
  • 不要使用Double类型创建BigDecimal对象,同样会损失精度;需要使用 Double.toString 转换成字符串
  • BigDecimal的equals()方法比较的是BigDecimal的value和scale,值相同但精度不同的数据使用equals进行比较时会返回false。如果希望只比较BigDecimal的value,可以使用compareTo方法
  • new BigDecimal("1.000").stripTrailingZeros()) stripTrailingZeros可以去除末尾的0
  • 防止计算溢出:使用Math类的addExact、subtractExact等xxExact方法,出现溢出时会抛出ArithmeticException异常
  • 防止Long转换溢出:BigInteger的longValueExact方法,转换溢出时会抛出ArithmeticException异常

chap2.10:List操作

1-Arrays.asList

  • Arrays.asList得到的是Arrays的内部类ArrayList,
  • 不能直接使用Arrays.asList来转换基本类型数组(如 int[])
  • Arrays.asList返回的List不支持增删操作(返回的是Arrays的内部类)
  • 对原始数组的修改会影响到我们获得的那个List
// Arrays.asList()
public static <T> List<T> asList(T... a) {
    return new ArrayList<>(a);
}

// Arrays的内部类
private static class ArrayList<E> extends AbstractList<E>
    implements RandomAccess, java.io.Serializable
{
    private static final long serialVersionUID = -2764017481108945198L;
    private final E[] a;

    // 构造方法用到了原数组,对原数组的操作会影响到返回的List
    ArrayList(E[] array) {
        a = Objects.requireNonNull(array);
    }
    
    // ...
}

2-List.subList

  • List.subList得到的是ArrayList的内部类SubList
  • subList方法会保留原List的引用,导致原List无法被回收
  • 修改子List中的元素会影响到原始List
  • 修改原始List增加再遍历子List,会出现ConcurrentModificationException
public List<E> subList(int fromIndex, int toIndex) {
    subListRangeCheck(fromIndex, toIndex, size);
    return new SubList(this, 0, fromIndex, toIndex);
}

// ArrayList 的内部类 SubList
private class SubList extends AbstractList<E> implements RandomAccess {
    private final AbstractList<E> parent;
    private final int parentOffset;
    private final int offset;
    int size;

    // SubList保留传入的parent的引用,使用 offset 和 index 进行索引
    SubList(AbstractList<E> parent,
            int offset, int fromIndex, int toIndex) {
        this.parent = parent;
        this.parentOffset = fromIndex;
        this.offset = offset + fromIndex;
        this.size = toIndex - fromIndex;
        this.modCount = ArrayList.this.modCount;
    }
    
    // ...
}

3-数据结构的选择

  • 使用HashMap替代List可以优化查询速度
  • 除了对头尾节点进行增删操作外,LinkedList 的性能可能远不如 ArrayList

chap2.11:空值处理:null和空指针问题

1-常用的处理null值的方法

  • Integer判空:Optional.ofNullable.orElse
  • String和字面量的比较:"字面量".equals(s) 或使用 Objects.equals()
  • 级联调用如controller.service.method() 可以使用 Optional.ofNullable.map.ifPresent 等处理
  • 处理返回结果中可能为null的list:Optional.ofNullable.orElse(Collections.emptyList())
  • Map数据结构
数据结构key是否支持null值value是否支持null值
HashMap支持支持
ConcurrentHashMap (线程安全)不支持不支持
HashTable (线程安全)不支持不支持
TreeMap不支持 (key要排序)支持

2-POJO中的null处理

  • DTO和PO最好分开使用,DTO处理客户端传入值,PO只处理数据库中需要更改的字段
  • DTO中的null可能有两种含义:1-用户希望赋值为null;2-用户不希望更改现有字段。可以在DTO中使用Optional<String> 进行处理:
@Data
public UserDTO {
    Optional<String> name;
}

// 客户端未传name:Optional<String> 为null,不做更新
// 客户端将name赋值为null:Optional<String> 不为null,orElse中使用空字符串替换
if (userDto.getName() != null) {
    userPo.setName(userDto.getName().orElse(""));
}

3-MySQL中null值字段的处理

  • sum函数没统计到任何记录时,会返回null而不是0,可以使用IFNULL(SUM(score), 0)把null转换为0
  • count不统计null值,需要使用COUNT(*)统计所有记录
  • 判断字段为null时,要使用 IS NULL 而不是 = null

4-不同ORM框架对动态sql的支持

  • Hibernate中可以使用 @DynamicUpdate 实现字段的动态更新
  • MyBatis支持动态sql:<if test="xxx != null"> xxx = #{xxx} </if>
  • MyBatisPlus 可以通过 @TableField 中的 insertStrategy, updateStrategy, whereStrategy 属性实现

chap2.12:异常处理

classDiagram
    class Error {
        不应被捕获和处理
    }

    class Exception {
        受检异常,需要显式try...catch或在方法签名中声明
    }

    class RuntimeException {
        不需要显式try...catch或在方法签名中声明
    }

    Throwable <| -- Error : extends
    Throwable <| -- Exception : extends
    Exception <| -- RuntimeException : extends

1-异常处理规范

  • 异常应该被log记录或重新抛出,如
try {
    // ...
} catch (Exception e) {
    // 1-完整记录异常消息、堆栈、类型等信息
    log.error(e);
    // 2-或重新抛出
    Throw new RuntimeException("msg", e);
}
  • 尽量在业务代码层处理异常,不要仅依靠框架实现异常捕获和处理
  • Repo层的异常可以忽略或降级
  • Service层往往涉及事务控制,不适合捕获异常
  • Controller层在处理异常时需要向用户提供友好信息

2-finally中的异常

  • 一个方法无法抛出两个异常,finally中的异常会覆盖try中的异常
  • 可以在finally中使用try...catch捕获和处理异常
  • 可以使用 addSuppressed 方法把finally中的异常附加到主异常上(try-with-resources中的做法)

3-JVML性能优化可能导致堆栈信息丢失

  • JVM的C2编译器优化会导致异常的堆栈信息丢失
  • 解决方法:1-使用 -XX:-OmitStackTraceInFastThrow

4-不能把异常定义为静态变量

@GetMapping("wrong")
public void wrong() {
    try {
        createOrderWrong();
    } catch (Exception ex) {
        log.error("createOrder got error", ex);
    }
    try {
        // 取消订单时抛出的异常实际时创建订单异常时的异常
        cancelOrderWrong();
    } catch (Exception ex) {
        log.error("cancelOrder got error", ex);
    }
}

private void createOrderWrong() {
    //这里有问题
    throw Exceptions.ORDEREXISTS;
}

private void cancelOrderWrong() {
    //这里有问题
    throw Exceptions.ORDEREXISTS;
}

public class Exceptions {
    public static BusinessException ORDEREXISTS = new BusinessException("订单已经存在", 3001);
}

4-线程池中的异常处理

  • 以 execute 方式向线程池提交任务时,异常会导致线程退出,线程池中的线程得不到复用

  • 以 submit 方式向线程池提交任务时,需要对返回的Future对象使用get方法获得执行结果或异常

  • 以 execute 方式向线程池提交任务时,可以对异常进行如下处理:

    • 1-在任务内部做好异常处理
    • 2-定义线程池时指定异常处理逻辑
new ThreadFactoryBuilder()
	.setNameFormat(prefix+"%d")
	.setUncaughtExceptionHandler((thread, throwable)-> log.error("ThreadPool {} got exception", thread, throwable))
	.get()
  • Future的get方法:
public V get() throws InterruptedException, ExecutionException {
    int s = state;
    if (s <= COMPLETING)
        s = awaitDone(false, 0L);
    return report(s);
}

private V report(int s) throws ExecutionException {
    Object x = outcome;
    if (s == NORMAL)
        return (V)x;
    if (s >= CANCELLED)
        throw new CancellationException();
    throw new ExecutionException((Throwable)x);
}

chap2.13:日志记录

1-日志配置问题

  • 以logback为例
  • logger的继承关系导致日志记录重复。自定义的logger会继承root中配置的appender,可以设置自定义的looger中additivity属性为false,避免appender重复导致的日志重复记录
<loggers>
    <logger name="logger_com.sankuai.rocs.cas.general.rebrush.log" level="info" additivity="false">
            <appender-ref ref="AsyncScribeAppender_com.sankuai.rocs.cas.general.rebrush.log"/>
    </logger>
    <root level="info">
        <appender-ref ref="infoAppender"/>
        <appender-ref ref="warnAppender"/>
        <appender-ref ref="errorAppender"/>
        <appender-ref ref="catAppender"/>
        <!--日志中心接入-->
        <appender-ref ref="ScribeAsyncAppender"/>
    </root>
</loggers>
  • LevelFilter 和 ThresholdFilter 的使用
    • ThresholdFilter中,如果日志级别大于等于配置的级别时返回NEUTRAL,继续调用过滤器链上下一个过滤器的方法,否则返回DENY直接拒绝记录日志
    • LevelFilter中,如果日志级别匹配则返回onMatch属性,反之返回onMissmatch属性。所以在配置level属性后要相应的配置onMatch和onMissmatch方法属性
<appenders>
    <!--只记录info级别日志-->
    <XMDFile name="infoAppender" fileName="info.log" sizeBasedTriggeringSize="512M" rolloverMax="30">
        <Filters>
            <ThresholdFilter level="warn" onMatch="DENY" onMismatch="NEUTRAL"/>
            <ThresholdFilter level="info" onMatch="ACCEPT" onMismatch="DENY"/>
        </Filters>
    </XMDFile>
</appenders>
  • EvaluatorFilter
    • 根据配置的marker属性为条件过滤需要记录的日志
<appenders>
        <filter class="...filter.EvaluatorFilter">
            <evaluator class="....OnMarkerEvaluator">
                <marker>time</marker>
            </evaluator>
            <onMismatch>DENY</onMismatch>
            <onMatch>ACCEPT</onMatch>
        </filter>
</appenders>

使用:

Marker timeMarker = MarkerFactory.getMarker("time");
log.info(timeMarker, "test");

2-异步日志记录

  • 使用 AsyncAppender 进行异步日志记录,几个关键参数:
    • queueSize:阻塞队列长度。默认值256过小,但设置过大可能会导致OOM

    • discardingThredshold:队列容量剩余空间小于设置值时,会丢弃新添加的任务

    • neverBlock:默认值为false,意味着提交日志记录时可能会发生阻塞(会通过put而不是offer方法向阻塞队列中添加任务)

3-占位符和日志级别判断

  • 处理耗时较长的日志任务时,可以先判断当前日志级别,再进行日志记录,如:
if (log.isDebugEnabled()) {
    log.debug("{}", slowMethod());
}
  • 使用 Log4j2 日志门面时,可以通过传入lambda表达式,将方法延迟到真正需要记录日志时再进行调用
log.debug("{}", () -> slowMethod());

4-MDC

  • MDC: mapped diagnostic context,本质上是ThreadLocal
  • 将MDC注册为Spring容器中的Filter后,可以在日志记录中直接使用MDC中的属性

chap2.14:文件IO

  • 如果需要读写字符流,那么需要确保文件中字符的字符集和字符流的字符集是一致的,否则可能产生乱码。
  • 使用Files类的一些流式处理操作,注意使用try-with-resources包装Stream,确保底层文件资源可以释放,避免产生too many open files的问题。
  • 进行文件字节流操作的时候,一般情况下不考虑进行逐字节操作,使用缓冲区进行批量读写减少IO次数,性能会好很多。一般可以考虑直接使用缓冲输入输出流BufferedXXXStream,追求极限性能的话可以考虑使用FileChannel进行流转发。

chap2.15:序列化

Redis序列化与反序列化

  • 序列化和反序列化需要确保算法一致。如RedisTemplate和StringRedisTemplate不能混用,因为前者默认使用JDK的序列化方式,而后者使用String的序列化方式
  • 可以自定义RedisTemplate的Key和Value的序列化方式
@Bean
public  RedisTemplate redisTemplate(RedisConnectionFactory redisConnectionFactory) {
    RedisTemplate redisTemplate = new RedisTemplate<>();
    redisTemplate.setConnectionFactory(redisConnectionFactory);
    
    // key使用String序列化,value使用Jackson提供的JSON序列化
    redisTemplate.setKeySerializer(RedisSerializer.string());
    redisTemplate.setValueSerializer(RedisSerializer.json());
    redisTemplate.setHashKeySerializer(RedisSerializer.string());
    redisTemplate.setHashValueSerializer(RedisSerializer.json());
    
    return redisTemplate;
}

MyBatisPlus读取List json字段

  • MyBatis读取List<T>时由于范型擦除的问题,会将List<T>读取为List<LinkedHashMap>,在进行类型转换时会报错
  • 解决方案:提供自定义的TypeHandler
public class ListTypeHandler<T> extends BaseTypeHandler<List<T>> {
    // ...
}

ObjectMapper

  • 在开发Spring Web应用程序时,如果自定义了 ObjectMapper,并把它注册成了Bean,那很可能会导致Spring Web使用的ObjectMapper也被替换
  • 不建议自定义ObjectMapper,可以修改默认ObjectMapper参数设置或定义Jackson2ObjectMapperBuilderCustomizer启动新特性
# 使用索引序列化枚举值
spring.jackson.serialization.write_enums_using_index=true
@Bean
public Jackson2ObjectMapperBuilderCustomizer customizer(){
    // 使用索引序列化枚举值
    return builder -> builder.featuresToEnable(SerializationFeature.WRITE_ENUMS_USING_INDEX);
}

反序列化时要小心类的构造方法

  • 默认情况下,在反序列化的时候,Jackson框架只会调用无参构造方法创建对象
  • 对于需要序列化的POJO考虑尽量不要自定义构造方法

枚举类不适合作为API接口参数

  • 客户端和服务端的枚举定义不一致时,客户端反序列化时会出异常
  • 枚举序列化反序列化实现自定义的字段非常麻烦

chap2.16:时间日期

image.png

@Test
public void testZonedDateTime() {
    // 时间-字符串
    String stringDate = "2020-01-02 22:00:00";
    // 时区
    ZoneId timeZoneShanghai = ZoneId.of("Asia/Shanghai");
    // 日期格式
    DateTimeFormatter dateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
    // 带有时区信息的时间 ZonedDateTime
    ZonedDateTime zonedDateTime = ZonedDateTime.of(LocalDateTime.parse(stringDate, dateTimeFormatter), timeZoneShanghai);

    //使用DateTimeFormatter格式化时间,可以通过withZone方法直接设置格式化使用的时区
    DateTimeFormatter outputFormat = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss Z");
    // Asia/Shanghai 2020-01-02 22:00:00 +0800
    System.out.println(timeZoneShanghai.getId() + " " + outputFormat.withZone(timeZoneShanghai).format(zonedDateTime));
}

@Test
public void testDateTimeFormatter() {
    DateTimeFormatter dateTimeFormatter = new DateTimeFormatterBuilder()
            .appendValue(ChronoField.YEAR).appendLiteral(" ")
            .appendValue(ChronoField.MONTH_OF_YEAR).appendLiteral(" ")
            .appendValue(ChronoField.DAY_OF_MONTH).appendLiteral(" ")
            .appendValue(ChronoField.HOUR_OF_DAY).appendLiteral(":")
            .appendValue(ChronoField.MINUTE_OF_HOUR)
            .toFormatter();

    LocalDateTime localDateTime = LocalDateTime.parse("2025 12 20 12:00", dateTimeFormatter);

    System.out.println(localDateTime.format(dateTimeFormatter));
}

chap2.17:OOM问题

  • 在进行容量评估时,我们不能认为一份数据在程序内存中也是一份。例如100M的数据加载到程序内存中,变为Java的数据结构就已经占用了200M堆内存;这些数据经过JDBC、MyBatis等框架其实是加载了2份,然后领域模型、DTO再进行转换可能又加载了2次;最终,占用的内存达到了200M*4=800M。

  • 即使使用WeakHashMap,不释放key的引用也会出现OOM问题。如以User为key,UserProfile为value,value中包含了对key的引用,即使代码中没有直接使用key的引用,value中引用了key也会导致Entry对象不能被回收

  • Tomcat参数配置不合理导致OOM:Http11InputBuffer其中一个初始化方法会分配headerBufferSize+readBuffer大小的内存,其中readBuffer默认是8192字节,如果headerBufferSize参数调整至过大,有可能导致OOM问题(request header过大有可能导致默认的headerBufferSize不够,需要合理调整headerBufferSize参数,满足业务需求的同时避免OOM)

chap2.18:反射、范型、注解和OOP

  • 反射获取类成员,需要注意getXXX和getDeclaredXXX方法的区别,其中XXX包括Methods、Fields、Constructors、Annotations。这两类方法,针对不同的成员类型XXX和对象,在实现上都有一些细节差异
  • getDeclaredMethods方法无法获得父类定义的方法,而getMethods方法可以,只是差异之一
  • 泛型因为类型擦除会导致泛型方法T占位符被替换为Object,子类如果使用具体类型覆盖父类实现,编译器会生成桥接方法。这样既满足子类方法重写父类方法的定义,又满足子类实现的方法有具体的类型。使用反射来获取方法清单时,需要特别注意这一点
Arrays.stream(child2.getClass().getDeclaredMethods())
        .filter(method -> method.getName().equals("setValue") && !method.isBridge())
        .findFirst().ifPresent(method -> {
    try {
        method.invoke(chi2, "test");
    } catch (Exception e) {
        e.printStackTrace();
    }
});
  • 自定义注解可以通过标记元注解@Inherited实现注解的继承,不过这只适用于类。如果要继承定义在接口或方法上的注解,可以使用Spring的工具类AnnotatedElementUtils
AnnotatedElementUtils.findMergedAnnotation(child.getClass().getMethod("foo");