最佳实践与踩坑
[TOC]
Noah-Java最佳实践与踩坑
Spring声明式事务最佳实践和踩坑
大多数业务开发同学都有事务的概念,也知道如果整体考虑多个数据库操作要么成功要么失败时,需要通过数据库事务来实现多个操作的一致性和原子性。但,在使用.上大多仅限于为方法标记@Transactional,不会去关注事务是否有效、出错后事务是否正确回滚,也不会考虑复杂的业务代码中涉及多个子业务逻辑时,怎么正确处理事务。
小心Spring的事务可能没有生效
- @Transactional 生效原则 1:除非特殊配置(比如使用 AspectJ 静态织入实现 AOP),否则只有定义在 public 方法上的 @Transactional 才能生效。原因是,Spring 默认通过动态代理的方式实现 AOP,对目标方法进行增强,private 方法无法代理到, Spring 自然也无法动态增强事务处理逻辑。
- @Transactional 生效原则 2:必须通过代理过的类从外部调用目标方法才能生效。
- CGLIB 通过继承方式实现代理类,private 方法在子类不可见,自然也就无法进行事务增 强;
- this 指针代表对象自己,Spring 不可能注入 this,所以通过 this 访问方法必然不是代 理。
- 一张图来回顾下 this 自调用、通过 self 调用,以及在 Controller 中调用 UserService 三种实现的区别
强烈建议你在开发时打开相关的 Debug 日志,以方便了解 Spring 事务实现的细节,并及时判断事务的执行情况。
- logging.level.org.springframework.orm.jpa=DEBUG
//this方式:在调用数据库的时候才开启了事务 |
事务即便生效也不一定能回滚
通过 AOP 实现事务处理可以理解为,使用 try…catch…来包裹标记了 @Transactional 注 解的方法,当方法出现了异常并且满足一定条件的时候,在 catch 里面我们可以设置事务 回滚,没有异常则直接提交事务。
只有异常传播出了标记了 @Transactional 注解的方法,事务才能回滚。
- 解决方案:TransactionAspectSupport.currentTransactionStatus().setRollbackOnly();
- 手动请求回滚在catch内调用
默认情况下,出现 RuntimeException(非受检异常)或 Error 的时候,Spring 才会回滚事务。
- @Transactional(rollbackFor = Exception.class)
- 受检异常也支持回滚操作
源码解释
/**
* The default behavior is as with EJB: rollback on unchecked exception
* ({@link RuntimeException}), assuming an unexpected outcome outside of any
* business rules. Additionally, we also attempt to rollback on {@link Error} which
* is clearly an unexpected outcome as well. By contrast, a checked exception is
* considered a business exception and therefore a regular expected outcome of the
* transactional business method, i.e. a kind of alternative return value which
* still allows for regular completion of resource operations.
* <p>This is largely consistent with TransactionTemplate's default behavior,
* except that TransactionTemplate also rolls back on undeclared checked exceptions
* (a corner case). For declarative transactions, we expect checked exceptions to be
* intentionally declared as business exceptions, leading to a commit by default.
* @see org.springframework.transaction.support.TransactionTemplate#execute
*/
org.springframework.transaction.interceptor.DefaultTransactionAttribute#rollbackOn
org.springframework.transaction.interceptor.TransactionAspectSupport#invokeWithinTransaction
Object retVal;
try {
// This is an around advice: Invoke the next interceptor in the chain.
// This will normally result in a target object being invoked.
retVal = invocation.proceedWithInvocation();
}
catch (Throwable ex) {
// target invocation exception
completeTransactionAfterThrowing(txInfo, ex);
throw ex;
}
finally {
cleanupTransactionInfo(txInfo);
}
请确认事务传播配置是否符合自己的业务逻辑
在有些业务逻辑中,可能 会包含多次数据库操作,我们不一定希望将两次操作作为一个事务来处理,这时候就需要仔 细考虑事务传播的配置了,否则也可能踩坑。
有一个场景:一个用户注册的操作,会插入一个主用户到用户表,还会注册一个关联的 子用户。我们希望将子用户注册的数据库操作作为一个独立事务来处理,即使失败也不会影 响主流程,即不影响主用户的注册。
//如第 1 行所示,对 createUserWrong2 方法开启了异常处理; |
- 奇怪的是,如第 11 行和 12 行所示,Controller 里出现了一个 UnexpectedRollbackException,异常描述提示最终这个事务回滚了,而且是静默回 滚的。之所以说是静默,是因为 createUserWrong2 方法本身并没有出异常,只不过提 交后发现子方法已经把当前事务设置为了回滚,无法完成提交。
- 解决方案:
- 修复方式就很明确了,想办法让子逻辑在独立事务中运行,也就是改一下 SubUserService 注册子用户的方法。
- 为注解加上 propagation = Propagation.REQUIRES_NEW 来设置 REQUIRES_NEW 方式的事务传播策略,也就是执 行到这个方法时需要开启新的事务,并挂起当前事务
- @Transactional(propagation = Propagation.REQUIRES_NEW)
HTTP调用:你考虑到超时、重试、并发了吗?
进行 HTTP 调用本质上是通过 HTTP 协议进行一次网络请求。网络 请求必然有超时的可能性,因此我们必须考虑到这三点:
- 框架设置的默认超时是否合理;
- 考虑到网络的不稳定,超时后的请求重试是一个不错的选择,但需要考虑服务端 接口的幂等性设计是否允许我们重试;
- 需要考虑框架是否会像浏览器那样限制并发连接数,以免在服务并发很大的情况 下,HTTP 调用的并发数限制成为瓶颈。
如果使用 Spring Cloud 进行微服务开 发,就会使用 Feign 进行声明式的服务调用。
如果使用 Java 中最常用的 HTTP 客户端 Apache HttpClient 进行服务调用。
配置连接超时和读取超时参数的学问
对于 HTTP 调用,虽然应用层走的是 HTTP 协议,但网络层面始终是 TCP/IP 协议。 TCP/IP 是面向连接的协议,在传输数据之前需要建立连接。几乎所有的网络框架都会提供 这么两个超时参数:
- 连接超时参数 ConnectTimeout,让用户配置建连阶段的最长等待时间;
- 读取超时参数 ReadTimeout,用来控制从 Socket 上读取数据的最长等待时间。
连接超时参数和连接超时的误区有这么两个:
- 连接超时配置得特别长,比如 60 秒。TCP 三次握手建立连接需要的时间非 常短,通常在毫秒级最多到秒级。(1-5秒)即可
- 排查连接超时问题,却没理清连的是哪里。
读取超时参数和读取超时则会有更多的误区
- **第一个误区:**认为出现了读取超时,服务端的执行就会中断。
- **第二个误区:**认为读取超时只是 Socket 网络层面的概念,是数据传输的最长耗时,故将其 配置得非常短,比如 100 毫秒。大部门时间是服务端处理业务逻辑的时间。
- **第三个误区:**认为超时时间越长任务接口成功率就越高,将读取超时参数配置得太长。
- 对定时任务或异步任务来说,读取超时配置得长些问题不大。
- 但面向用户响应的请求或是微 服务短平快的同步接口调用,并发量一般较大,我们应该设置一个较短的读取超时时间,以 防止被下游服务拖慢,通常不会设置超过 30 秒的读取超时。
Feign 和 Ribbon 配合使用,你知道怎么配置超时吗?
结论一,默认情况下 Feign 的读取超时是 1 秒,如此短的读 取超时算是坑点一。
源码分析:org.springframework.cloud.netflix.ribbon.RibbonClientConfiguration#ribbonClientConfig
/**
* Ribbon client default connect timeout.
*/
public static final int DEFAULT_CONNECT_TIMEOUT = 1000;
/**
* Ribbon client default read timeout.
*/
public static final int DEFAULT_READ_TIMEOUT = 1000;
/**
* Ribbon client default Gzip Payload flag.
*/
public static final boolean DEFAULT_GZIP_PAYLOAD = true;
private String name = "client";
// TODO: maybe re-instate autowired load balancers: identified by name they could be
// associated with ribbon clients
private PropertiesFactory propertiesFactory;
public IClientConfig ribbonClientConfig() {
DefaultClientConfigImpl config = new DefaultClientConfigImpl();
config.loadProperties(this.name);
config.set(CommonClientConfigKey.ConnectTimeout, DEFAULT_CONNECT_TIMEOUT);
config.set(CommonClientConfigKey.ReadTimeout, DEFAULT_READ_TIMEOUT);
config.set(CommonClientConfigKey.GZipPayload, DEFAULT_GZIP_PAYLOAD);
return config;
}
结论二,也是坑点二,如果要配置 Feign 的读取超时,就必须同时配置连接超时,才能生 效。
源码分析:org.springframework.cloud.openfeign.FeignClientFactoryBean#configureUsingProperties
if (config.getConnectTimeout() != null && config.getReadTimeout() != null) {
builder.options(new Request.Options(config.getConnectTimeout(),
config.getReadTimeout()));
}
结论三,单独的超时可以覆盖全局超时,这符合预期,不算坑:
结论四,除了可以配置 Feign,也可以配置 Ribbon 组件的参数来修改两个超时时间。这 里的坑点三是,参数首字母要大写,和 Feign 的配置不同。
结论五,同时配置 Feign 和 Ribbon 的超时,以 Feign 为准。
源码分析:org.springframework.cloud.openfeign.ribbon.LoadBalancerFeignClient#getClientConfig
IClientConfig getClientConfig(Request.Options options, String clientName) {
IClientConfig requestConfig;
if (options == DEFAULT_OPTIONS) {
requestConfig = this.clientFactory.getClientConfig(clientName);
}
else {
requestConfig = new FeignOptionsClientConfig(options);
}
return requestConfig;
}
配置大全
## fegin默认读取超时配置和链接超时配置 |
你是否知道Ribbon会自动重试请求呢?
显然,这说明客户端自作主张进 行了一次重试(GET请求),导致短信重复发送。
源码分析
com.netflix.client.config.DefaultClientConfigImpl
//MaxAutoRetriesNextServer 参数默认为 1,也就是 Get 请求在某个服务端节点出现问题(比如读取超时)时,Ribbon 会自动重试一次:
public static final int DEFAULT_MAX_AUTO_RETRIES_NEXT_SERVER = 1;
public static final int DEFAULT_MAX_AUTO_RETRIES = 0;
org.springframework.cloud.netflix.ribbon.RibbonLoadBalancedRetryPolicy
getMaxRetriesOnNextServer==DEFAULT_MAX_AUTO_RETRIES_NEXT_SERVER解决方案:
- 把发短信接口从 Get 改为 Post。其实,这里还有一个 API 设计问题,有状态的 API 接口不应该定义为 Get。根据 HTTP 协议的规范,Get 请求用于数据查询(无状态,幂等),而 Post 才是把数据提交到服务端用于修改或新增。选择 Get 还是 Post 的依据,应该是 API 的 行为,而不是参数大小。这里的一个误区是,Get 请求的参数包含在 Url QueryString中,会受浏览器长度限制,所以一些同学会选择使用 JSON 以 Post 提交大参数,使用 Get 提交小参数。
- 将 MaxAutoRetriesNextServer 参数配置为 0,禁用服务调用失败后在下一个服 务端节点的自动重试。在配置文件中添加一行即可。(todo:源码实现)
并发限制了爬虫的抓取能力
源码分析:org.apache.http.impl.conn.PoolingHttpClientConnectionManager构造器。
关键两个参数,final int defaultMaxPerRoute, final int maxTotal
defaultMaxPerRoute=2,也就是同一个主机 / 域名的最大并发请求数为 2。我们的爬 虫需要 10 个并发,显然是默认值太小限制了爬虫的效率。
maxTotal=20,也就是所有主机整体最大并发为 20,这也是 HttpClient 整体的并发 度。目前,我们请求数是 10 最大并发是 10,20 不会成为瓶颈。举一个例子,使用同一 个 HttpClient 访问 10 个域名,defaultMaxPerRoute 设置为 10,为确保每一个域名 都能达到 10 并发,需要把 maxTotal 设置为 100。
源码:
/**
* @since 4.4
*/
public PoolingHttpClientConnectionManager(
final HttpClientConnectionOperator httpClientConnectionOperator,
final HttpConnectionFactory<HttpRoute, ManagedHttpClientConnection> connFactory,
final long timeToLive, final TimeUnit timeUnit) {
super();
this.configData = new ConfigData();
this.pool = new CPool(new InternalConnectionFactory(
this.configData, connFactory), 2, 20, timeToLive, timeUnit);
this.pool.setValidateAfterInactivity(2000);
this.connectionOperator = Args.notNull(httpClientConnectionOperator, "HttpClientConnectionOperator");
this.isShutDown = new AtomicBoolean(false);
}
public CPool(
final ConnFactory<HttpRoute, ManagedHttpClientConnection> connFactory,
final int defaultMaxPerRoute, final int maxTotal,
final long timeToLive, final TimeUnit timeUnit) {
super(connFactory, defaultMaxPerRoute, maxTotal);
this.timeToLive = timeToLive;
this.timeUnit = timeUnit;
}
数据库索引:索引并不是万能药
InnoDB是如何存储数据的
- MySQL 把数据存储和查询操作抽象成了存储引擎,不同的存储引擎,对数据的存储和读取 方式各不相同。MySQL 支持多种存储引擎,并且可以以表为粒度设置存储引擎。因为支持 事务,我们最常使用的是 InnoDB。
- 虽然数据保存在磁盘中,但其处理是在内存中进行的。为了减少磁盘随机读取次数, InnoDB 采用页而不是行的粒度来保存数据,即数据被分成若干页,以页为单位保存在磁盘 中。InnoDB 的页大小,一般是 16KB。
- 各个数据页组成一个双向链表,每个数据页中的记录按照主键顺序组成单向链表;每一个数 据页中有一个页目录,方便按照主键查询记录。数据页的结构如下:
- 页目录通过槽把记录分成不同的小组,每个小组有若干条记录。如图所示,记录中最前面的 小方块中的数字,代表的是当前分组的记录条数,最小和最大的槽指向 2 个特殊的伪记 录。有了槽之后,我们按照主键搜索页中记录时,就可以采用二分法快速搜索,无需从最小 记录开始遍历整个页中的记录链表。
- 举一个例子,如果要搜索主键(PK)=15 的记录:
- 先二分得出槽中间位是 (0+6)/2=3,看到其指向的记录是 12<15,所以需要从 #3 槽后 继续搜索记录;
- 再使用二分搜索出 #3 槽和 #6 槽的中间位是 (3+6)/2=4.5 取整 4,#4 槽对应的记录是 16>15,所以记录一定在 #4 槽中;
- 再从 #3 槽指向的 12 号记录开始向下搜索 3 次,定位到 15 号记录。
聚簇索引和二级索引
说到索引,页目录就是最简单的索引,是通过对记录进行一级分组来降低搜索的时间复杂 度。但,这样能够降低的时间复杂度数量级,非常有限。当有无数个数据页来存储表数据的 时候,我们就需要考虑如何建立合适的索引,才能方便定位记录所在的页。
B+ 树的特点包括:
- 最底层的节点叫作叶子节点,用来存放数据;
- 其他上层节点叫作非叶子节点,仅用来存放目录项,作为索引;
- 非叶子节点分为不同层次,通过分层来降低每一层的搜索量;
- 所有节点按照索引键大小排序,构成一个双向链表,加速范围查找。
因此,InnoDB 使用 B+ 树,既可以保存实际数据,也可以加速数据搜索,这就是聚簇索 引。如果把上图叶子节点下面方块中的省略号看作实际数据的话,那么它就是聚簇索引的示 意图。由于数据在物理上只会保存一份,所以包含实际数据的聚簇索引只能有一个。
- InnoDB 会自动使用主键(唯一定义一条记录的单个或多个字段)作为聚簇索引的索引键 (如果没有主键,就选择第一个不包含 NULL 值的唯一列)。上图方框中的数字代表了索 引键的值,对聚簇索引而言一般就是主键。
- 我们再看看 B+ 树如何实现快速查找主键。比如,我们要搜索 PK=4 的数据,通过根节点 中的索引可以知道数据在第一个记录指向的 2 号页中,通过 2 号页的索引又可以知道数据 在 5 号页,5 号页就是实际的数据页,然后再通过二分法查找页目录马上可以找到记录的 指针。
二级索引:为了实现非主键字段的快速搜索
- 这次二级索引的叶子节点中保存的不是实际数据,而是主键,获得主键值后去聚簇索引中获 得数据行。这个过程就叫作回表。
- 举个例子,有个索引是针对用户名字段创建的,索引记录上面方块中的字母是用户名,按照 顺序形成链表。如果我们要搜索用户名为 b 的数据,经过两次定位可以得出在 #5 数据页 中,查出所有的主键为 7 和 6,再拿着这两个主键继续使用聚簇索引进行两次回表得到完 整数据。
考虑额外创建二级索引的代价
创建二级索引的代价,主要表现在维护代价、空间代价和回表代价三个方面。
- 首先是维护代价。创建 N 个二级索引,就需要再创建 N 棵 B+ 树,新增数据时不仅要修改 聚簇索引,还需要修改这 N 个二级索引。
- 这里,我再额外提一下,页中的记录都是按照索引值从小到大的顺序存放的,新增记录就需 要往页中插入数据,现有的页满了就需要新创建一个页,把现有页的部分数据移过去,这就 是页分裂;如果删除了许多数据使得页比较空闲,还需要进行页合并。页分裂和合并,都会 有 IO 代价,并且可能在操作过程中产生死锁。
- 其次是空间代价。虽然二级索引不保存原始数据,但要保存索引列的数据,所以会占用更多 的空间。
- 查看表数据长度和索引长度大小:
select DATA_LENGTH, INDEX_LENGTH from information_schema.TABLES where TABLE_NAME='person';
- 查看表数据长度和索引长度大小:
- 最后是回表的代价。二级索引不保存原始数据,通过索引找到主键后需要再查询聚簇索引, 才能得到我们要的数据。
- explain分析:Extra 列多了一行 Using index 的提示,证明这次查询直接查的是二级索引,免 去了回表。
- 联合索引示意图
- 索引覆盖:在联合索引中直接查找到需要的字段数据。
索引开销最佳实践
- 第一,无需一开始就建立索引,可以等到业务场景明确后,或者是数据量超过 1 万、查询 变慢后,再针对需要查询、排序或分组的字段创建索引。创建索引后可以使用 EXPLAIN 命 令,确认查询是否可以使用索引。
- 第二,尽量索引轻量级的字段,比如能索引 int 字段就不要索引 varchar 字段。索引字段也 可以是部分前缀,在创建的时候指定字段索引长度。针对长文本的搜索,可以考虑使用 Elasticsearch 等专门用于文本搜索的索引数据库。
- 第三,尽量不要在 SQL 语句中 SELECT *,而是 SELECT 必要的字段,甚至可以考虑使用联 合索引来包含我们要搜索的字段,既能实现索引加速,又可以避免回表的开销。
不是所有针对索引列的查询都能用上索引
两个问题,引发的思考:
- 是不是建了索引一定可以用上?
- 怎么选择创建联合索引还是多个独立索引?
索引失效的情况
第一,索引只能匹配列前缀,对
like %xxxx
无效原因很简单,索引 B+ 树中行数据按照索引值排序,只能根据前缀进行比较。
必要要实现后缀匹配,可以考虑把数据反过来存储。(todo:实践)
第二,条件涉及函数操作无法走索引,比如搜索条件用到了 LENGTH 函数,
- 同样的原因,索引保存的是索引列的原始值,而不是经过函数计算后的值。
- 只能保存一份函数变换后的值,然后重新针对这个计算列做索 引。(todo:实践)
第三,联合索引只能匹配左边的列。
- 也就是说,虽然对 name 和 score 建了联合索引,但 是仅按照 score 列搜索无法走索引:
- 原因也很简单,在联合索引的情况下,数据是按照索引第一列排序,第一列数据相同时才会 按照第二列排序。也就是说,如果我们想使用联合索引中尽可能多的列,查询条件中的各个 列必须是联合索引中从最左边开始连续的列。
- 需要注意的是,因为有查询优化器,所以 name 作为 WHERE 子句的第几个条件并不是很 重要。(本质通过查询优化器,where的查询条件会被优化顺序,命中联合索引)
- 是不是建了索引一定可以用上?
- 是不是建了索引一定可以用上?并不是,只有当查询能符合索引存储的实际结构时,才 能用上。这里,我只给出了三个肯定用不上索引的反例。其实,有的时候即使可以走索 引,MySQL 也不一定会选择使用索引。我会在下一小节展开这一点。
- 怎么选择创建联合索引还是多个独立索引?
- 怎么选择建联合索引还是多个独立索引?如果你的搜索条件经常会使用多个字段进行搜 索,那么可以考虑针对这几个字段建联合索引;同时,针对多字段建立联合索引,使用 索引覆盖的可能更大。如果只会查询单个字段,可以考虑建单独的索引,毕竟联合索引 保存了不必要字段也有成本。
数据库基于成本决定是否走索引
通过前面的案例,我们可以看到,查询数据可以直接在聚簇索引上进行全表扫描,也可以走 二级索引扫描后到聚簇索引回表。看到这里,你不禁要问了,MySQL 到底是怎么确定走哪 种方案的呢。
如何计算查询成本?
- IO成本:是从磁盘把数据加载到内存的成本。默认情况下,读取数据页的 IO 成本常数 是 1(也就是读取 1 个页成本是 1)。
- CPU成本:是检测数据是否满足条件和排序等 CPU 操作的成本。默认情况下,检测记 录的成本是 0.2。
全表扫描的成本?
- 全表扫描,就是把聚簇索引中的记录依次和给定的搜索条件做比较,把符合搜索条件的记录 加入结果集的过程
- 聚簇索引占用的页面数,用来计算读取数据的 IO 成本
- (页大小=聚簇索引占用的空间/每个页的大小)=>data_length/innodb每页大小为16kb
- 表中的记录数,用来计算搜索的 CPU 成本(rows * 0.2)
SHOW TABLE STATUS LIKE 'person';
- 因此我们可以计算出:10w的数据,row=10086,Data_length=5783552 byte,IO成本=289,cpu成本=20017,因此全表扫描的成本=20306
两个结论:
- MySQL 选择索引,并不是按照 WHERE 条件中列的顺序进行的;
- 即便列有索引,甚至有多个可能的索引方案,MySQL 也可能不走索引。
EXPLAIN SELECT * FROM person FORCE INDEX(name_score) WHERE xx=xx
,使用走强制索引
用 optimizer trace 功能查看优化器生成执行 计划的整个过程
mysql计算查询成本sql:
SET optimizer_trace="enabled=on";
explain select * from person where NAME >'name84059' and create_time>'2020-04-18 00:00:00';
select * from information_schema.OPTIMIZER_TRACE;
SET optimizer_trace="enabled=off";成本结果分析:
"analyzing_range_alternatives":{
"range_scan_alternatives":[
{
"index":"name_score",
"ranges":[
"name84059 < name" //条件命中索引成本计算
],
"index_dives_for_eq_ranges":true,
"rowid_ordered":false,
"using_mrr":false,
"index_only":false,
"rows":25362, //扫描行数
"cost":30435, //成本(二级索引+聚蔟索引查询成本)
"chosen":false,
"cause":"cost"
},
{
"index":"create_time",
"ranges":[
"0x5e9a4300 < create_time"
],
"index_dives_for_eq_ranges":true,
"rowid_ordered":false,
"using_mrr":false,
"index_only":false,
"rows":50067,
"cost":60081,
"chosen":false,
"cause":"cost"
}
],
"rows_estimation":[
{
"table":"`person`",
"range_analysis":{
"table_scan":{
"rows":100135,
"cost":20382 //全表成本
}
}
}]
判等问题:程序里如何确定你就是你?
注意equals和 == 区别
对基本类型,比如 int、long,进行判等,只能使用 ==,比较的是直接值。因为基本类 型的值就是其数值。
对引用类型,比如 Integer、Long 和 String,进行判等,需要使用 equals 进行内容判 等。因为引用类型的直接值是指针,使用 == 的话,比较的是指针,也就是两个对象在 内存中的地址,即比较它们是不是同一个对象,而不是比较对象的内容。
第一个结论:比较值的内容,除了基本类型只能使用 == 外,其他类型都需要使用 equals。
Integer和int使用 == 判断相等的情况
Integer a = 127;
Integer b = 127;
a == b ? true
Integer c = 128;
Integer d = 128;
c == d ? false
Integer e = 127;
Integer f = new Integer(127);
e == f ? false
Integer g = new Integer(127);
Integer h = new Integer(127);
g == h ? false
Integer i = 128;
int j = 128;
i == j ? true源码分析:Integer缓存了一部分的值[-128,127(默认值)],转换在内部其实做了缓存,使得两个 Integer 指向同一个对象。 JVM 参数加上 - XX:AutoBoxCacheMax=1000。只 需要记得比较 Integer 的值请使用 equals,而不是 ==
public static Integer valueOf(int i) {
if (i >= IntegerCache.low && i <= IntegerCache.high)
return IntegerCache.cache[i + (-IntegerCache.low)];
return new Integer(i);
}
注意枚举使用Integer来表示整数,使用==进行判等的问题。只有超过了127&&使用==进行判断的隐藏bug
谈谈字符串equals和 ==
字符串比较
String a = "1";
String b = "1";
a == b ? true
String c = new String("2");
String d = new String("2");c == d ? false
String e = new String("3").intern();
String f = new String("3").intern();
e == f ? true
String g = new String("4");
String h = new String("4");
g .equals h ? true我先和你说说 Java 的字符串常量池机制。首先要明确的是其设计初 衷是节省内存。当代码中出现双引号形式创建字符串对象时,JVM 会先对这个字符串进行 检查,如果字符串常量池中存在相同内容的字符串对象的引用,则将这个引用返回;否则, 创建新的字符串对象,然后将这个引用放入字符串常量池,并返回该引用。这种机制,就是 字符串驻留或池化。
虽然使用 new 声明的字符串调用 intern 方法,也可以让字符串进行驻留,但在业务代码 中滥用 intern,可能会产生性能问题。
通过循环把 1 到 1000 万之间的数字以字符串形式 intern 后,存入一个 List:
public int internperformance(int size) {
//-XX:+PrintStringTableStatistics
//-XX:StringTableSize=10000000
long begin = System.currentTimeMillis();
list = IntStream.rangeClosed(1, size)
.mapToObj(i -> String.valueOf(i).intern())
.collect(Collectors.toList());
log.info("size:{} took:{}", size, System.currentTimeMillis() - begin);
return list.size();
}
StringTable statistics:
Number of buckets : 60013 = 480104 bytes, avg 8.000
Number of entries : 10031852 = 240764448 bytes, avg 24.000
Number of literals : 10031852 = 563163800 bytes, avg 56.138
Total footprint : = 804408352 bytes
Average bucket size : 167.161
Variance of bucket size : 55.844
Std. dev. of bucket size: 7.473
Maximum bucket size : 198- Average bucket size : 167.161,表明每个桶的平均长度
- buckets:桶的大小
- 其实,原因在于字符串常量池是一个固定容量的 Map。如果容量太小(Number of buckets=60013)、字符串太多(1000 万个字符串),那么每一个桶中的字符串数量会 非常多,所以搜索起来就很慢。输出结果中的 Average bucket size=167,代表了 Map 中桶的平均长度是 167。
第二原则了:没事别轻易用 intern,如果要用一定要注意控制驻留的字符串的数量,并留意常量表的各项指标。
实现一个equals没有这么简单
对于自定义类型,如果不重写 equals 的话,默认就是使用 Object 基类的按引用的比较方 式。我们写一个自定义类测试一下。
自定义实现equals方法的,扣心自问2个问题。
- 比较一个 XX 对象和 null;
- 比较一个 Object 对象和一个 XX 对象;
通过这些失效的用例,我们大概可以总结出实现一个更好的 equals 应该注意的点:
考虑到性能,可以先进行指针判等,如果对象是同一个那么直接返回 true;
需要对另一方进行判空,空对象和自身进行比较,结果一定是 fasle;
需要判断两个对象的类型,如果类型都不同,那么直接返回 false;
确保类型相同的情况下再进行类型强制转换,然后逐一判断所有字段。
hashCode方法也需要重写,确保散列表的比较符合预期
代码实现:
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
PointRight that = (PointRight) o;
return x == that.x && y == that.y;
}
public int hashCode() {
return Objects.hash(x, y);
}
String源码的equals方法
public boolean equals(Object anObject) {
if (this == anObject) {
return true;
}
if (anObject instanceof String) {
String anotherString = (String)anObject;
int n = value.length;
if (n == anotherString.value.length) {
char v1[] = value;
char v2[] = anotherString.value;
int i = 0;
while (n-- != 0) {
if (v1[i] != v2[i])
return false;
i++;
}
return true;
}
}
return false;
}hashCode 和 equals 要配对实现。
- 确保散列表的使用符合预期
- 散列表需要使用 hashCode 来定位元素放到哪个桶。如果自定义 对象没有实现自定义的 hashCode 方法,就会使用 Object 超类的默认实现,得到的两个 hashCode 是不同的,导致无法满足需求。
Objects.hash
方法
注意 compareTo 和 equals 的逻辑一致性
Collections.binarySearch 方法内部调用了元素的 compareTo 方法进行比较;
- List.indexOf和Collections.binarySearch的算法实现不同O(n)和O(logn)。
compareTo标准实现
public int compareTo(StudentRight other) {
return Comparator.comparing(StudentRight::getName)
.thenComparingInt(StudentRight::getId)
.compare(this, other);
}对于自定义的类型,如果要实现 Comparable,请记得 equals、 hashCode、compareTo 三者逻辑一致。
小心 Lombok 生成代码的坑
- 使用
@Data
注解,包括了@EqualsAndHashCode
自动生成equals和hashcode方法。@EqualsAndHashCode.Exclude
排除equals和hashcode的实现中移除指定字段
- 说明 @EqualsAndHashCode 默认实现没有使用父类属性。
- 在继承关系中:
@EqualsAndHashCode(callSuper = true)
- 在继承关系中:
数值计算:注意精度、舍入和溢出问题
解决10%+10%,神器问题
危险的Double
对于计算 机而言,0.1 无法精确表达,这是浮点数计算造成精度损失的根源。
使用 BigDecimal 表示和计算浮点数,且务必使用字符串的构造方法来初始化 BigDecimal:
BigDecimal 有 scale 和 precision 的概念,scale 表 示小数点右边的位数,而 precision 表示精度,也就是有效数字的长度。
关于右边位数和精度问题
private static void testScale() {
BigDecimal bigDecimal1 = new BigDecimal("100");
BigDecimal bigDecimal2 = new BigDecimal(String.valueOf(100d));
BigDecimal bigDecimal3 = new BigDecimal(String.valueOf(100));
BigDecimal bigDecimal4 = BigDecimal.valueOf(100d);
BigDecimal bigDecimal5 = new BigDecimal(Double.toString(100));
log.info("scale {} precision {} result {}", bigDecimal.scale(), bigDecimal.precision(), bigDecimal.multiply(new BigDecimal("4.015")));
print(bigDecimal1); //scale 0 precision 3 result 401.500
print(bigDecimal2); //scale 1 precision 4 result 401.5000
print(bigDecimal3); //scale 0 precision 3 result 401.500
print(bigDecimal4); //scale 1 precision 4 result 401.5000
print(bigDecimal5); //scale 1 precision 4 result 401.5000
}
如果一定要用 Double 来初始化 BigDecimal 的话,可以使用 BigDecimal.valueOf 方 法
考虑浮点数舍入和格式化的方式
神器问题
double num1 = 3.35;
float num2 = 3.35f;
System.out.println(String.format("%.1f", num1));//四舍五入=3.4
System.out.println(String.format("%.1f", num2));//四舍五入=3.3- 这就是由精度问题和舍入方式共同导致的,double 和 float 的 3.35 其实相当于 3.350xxx 和 3.349xxx:
- String.format 采用四舍五入的方式进行舍入,取 1 位小数,double 的 3.350 四舍五入为 3.4,而 float 的 3.349 四舍五入为 3.3。
- 我们看一下 Formatter 类的相关源码,可以发现使用的舍入模式是 HALF_UP
所以浮点数避坑第二原则:浮点数的字符串格式化也要通过 BigDecimal 进行。
用 equals 做判等,就一定是对的吗?
问题:
System.out.println(new BigDecimal("1.0").equals(new BigDecimal("1")))
源码分析:value and scale 都需要比较,因此上面的问题是false
/**
* Compares this {@code BigDecimal} with the specified
* {@code Object} for equality. Unlike {@link
* #compareTo(BigDecimal) compareTo}, this method considers two
* {@code BigDecimal} objects equal only if they are equal in
* value and scale (thus 2.0 is not equal to 2.00 when compared by
* this method).
*
* @param x {@code Object} to which this {@code BigDecimal} is
* to be compared.
* @return {@code true} if and only if the specified {@code Object} is a
* {@code BigDecimal} whose value and scale are equal to this
* {@code BigDecimal}'s.
* @see #compareTo(java.math.BigDecimal)
* @see #hashCode
*/
public boolean equals(Object x) {
if (!(x instanceof BigDecimal))
return false;
BigDecimal xDec = (BigDecimal) x;
if (x == this)
return true;
if (scale != xDec.scale)
return false;
long s = this.intCompact;
long xs = xDec.intCompact;
if (s != INFLATED) {
if (xs == INFLATED)
xs = compactValFor(xDec.intVal);
return xs == s;
} else if (xs != INFLATED)
return xs == compactValFor(this.intVal);
return this.inflated().equals(xDec.inflated());
}如果我们希望只比较 BigDecimal 的 value,可以使用 compareTo 方法
- 方案:
System.out.println(new BigDecimal("1.0").compareTo(new BigDecimal("1"))==0);
- 方案:
那本质就是说compareTo与(equals和hashcode方法)实现的比较逻辑是不同,因此可以这样做。
问题:当容器HashSet或者HashMap,add(1.0)/put(1.0)之后,collect.contaion(1)的时候,返回false。如何解决
Set<BigDecimal> hashSet1 = new HashSet<>();
hashSet1.add(new BigDecimal("1.0"));
System.out.println(hashSet1.contains(new BigDecimal("1")));//返回false- 本质说明:HashSet和HashMap在对值进行比较的时候,调用的是equals和hashcode方法。而TreeSet和TreeMap调用的是compareTo方法。
- 第一个方法是,使用 TreeSet 替换 HashSet。TreeSet 不使用 hashCode 方法,也不使 用 equals 比较元素,而是使用 compareTo 方法,所以不会有问题。
- 第二个方法是,把 BigDecimal 存入 HashSet 或 HashMap 前,先使用 stripTrailingZeros 方法去掉尾部的零,比较的时候也去掉尾部的 0,确保 value 相同的 BigDecimal,scale 也是一致的:
小心数值溢出问题
数值计算还有一个要小心的点是溢出,不管是 int 还是 long,所有的基本数值类型都有超 出表达范围的可能性。
问题:数值计算溢出问题,显然这是发生了溢出,而且是默默的溢出,并没有任何异常
long l = Long.MAX_VALUE;
System.out.println(l + 1);
System.out.println(l + 1 == Long.MIN_VALUE);//true- 方法一是,考虑使用 Math 类的 addExact、subtractExact 等 xxExact 方法进行数值运 算,这些方法可以在数值溢出时主动抛出异常
- 方法二是,使用大数类 BigInteger。BigDecimal 是处理浮点数的专家,而 BigInteger 则 是对大数进行科学计算的专家
重点回顾
- 第一,切记,要精确表示浮点数应该使用 BigDecimal。并且,使用 BigDecimal 的 Double 入参的构造方法同样存在精度丢失问题,应该使用 String 入参的构造方法或者 BigDecimal.valueOf 方法来初始化。
- 第二,对浮点数做精确计算,参与计算的各种数值应该始终使用 BigDecimal,所有的计算 都要通过 BigDecimal 的方法进行,切勿只是让 BigDecimal 来走过场。任何一个环节出现 精度损失,最后的计算结果可能都会出现误差。
- 第三,对于浮点数的格式化,如果使用 String.format 的话,需要认识到它使用的是四舍五 入,可以考虑使用 DecimalFormat 来明确指定舍入方式。但考虑到精度问题,我更建议使 用 BigDecimal 来表示浮点数,并使用其 setScale 方法指定舍入的位数和方式。
- 第四,进行数值运算时要小心溢出问题,虽然溢出后不会出现异常,但得到的计算结果是完 全错误的。我们考虑使用 Math.xxxExact 方法来进行运算,在溢出时能抛出异常,更建议 对于可能会出现溢出的大数运算使用 BigInteger 类。
- 总之,对于金融、科学计算等场景,请尽可能使用 BigDecimal 和 BigInteger,避免由精 度和溢出问题引发难以发现,但影响重大的 Bug。
集合类:坑满地的List列表操作
xx大师说过,程序=数据结构+算法。Java 的集合类包括 Map 和 Collection 两大类。Collection 包 括 List、Set 和 Queue 三个小类,其中 List 列表集合是最重要也是所有业务代码都会用到的。
我们就从把数组转换为 List 集合、对 List 进行切片操作、List 搜索的性能问题等几 个方面着手。来谈谈java集合的坑。
使用 Arrays.asList 把数据转换为 List 的三个坑
第一个坑是,不能直接使用 Arrays.asList 来转换基本类型数组
问题:使用Arrays.asList初始化基本数据类型
int[] arr = {1, 2, 3};
List list = Arrays.asList(arr);
log.info("list:{} size:{} class:{}", list, list.size(), list.get(0).getClass());
//list:[[I@d7b1517] size:1 class:class [I
/**
* Arrays.asList的源码
**/
public static <T> List<T> asList(T... a) {
return new ArrayList<>(a);
}- 其原因是,只能是把 int 装箱为 Integer,不可能把 int 数组装箱为 Integer 数组。我们知 道,Arrays.asList 方法传入的是一个泛型 T 类型可变参数,最终 int 数组整体作为了一个 对象成为了泛型类型 T
- 解决方案1:对于基本数据类型的数组,使用
Arrays.stream(arr).boxed().collect(Collectors.toList());
先对基本数据类型的数组进行装箱。 - 解决方案2:只能把基本数据类型数组,声明为包装类型的数组
第二个坑,Arrays.asList 返回的 List 不支持增删操作
第三个坑,对原始数组的修改会影响到我们获得的那个 List。
问题:操作Arrays.asList生成的List
String[] arr = {"1", "2", "3"};
List list = Arrays.asList(arr);
arr[1] = "4";
try {
list.add("5");
} catch (Exception ex) {
ex.printStackTrace();
}
log.info("arr:{} list:{}", Arrays.toString(arr), list);
//java.lang.UnsupportedOperationException
//arr:[1, 4, 3] list:[1, 4, 3]源码分析:Arrays.asList 返回的 List 并不是 我们期望的 java.util.ArrayList,而是 Arrays 的内部类 ArrayList。ArrayList 内部类继承自 AbstractList 类,并没有覆写父类的 add 方法,而父类中 add 方法的实现,就是抛出 UnsupportedOperationException。
//java.util.AbstractList
public void add(int index, E element) {
throw new UnsupportedOperationException();
}看一下 java.util.Arrays.ArrayList内部类 的实现,可 以发现 ArrayList 其实是直接使用了原始的数组。所以,我们要特别小心,把通过 Arrays.asList 获得的 List 交给其他方法处理,很容易因为共享了数组,相互修改产生 Bug。
解决方案:使用真的ArrayList,而不是Arrays内部类的ArrayList。
List list = new ArrayList(Arrays.asList(arr));
使用 List.subList 进行切片操作居然会导致 OOM?
业务开发时常常要对 List 做切片处理,即取出其中部分元素构成一个新的 List,我们通常 会想到使用 List.subList 方法。但,和 Arrays.asList 的问题类似,List.subList 返回的子List 不是一个普通的 ArrayList。这个子 List 可以认为是原始 List 的视图,会和原始 List 相 互影响。如果不注意,很可能会因此产生 OOM 问题。
问题一:注意使用ArraysList.subList()方法,导致OOM,看源码!!
private static void oom() {
for (int i = 0; i < 1000; i++) {
List<Integer> rawList = IntStream.rangeClosed(1, 100000).boxed().collect(Collectors.toList());
System.out.println("运行了第" + i + "次,还是没有出现oom");
data.add(rawList.subList(0, 1));
}
}
//可以最大堆和初始化堆,更快看到效果OOM,Java heap space- 创建太多对象没有被收回(强引用),导致OOM。
- 出现 OOM 的原因是,循环中的 1000 个具有 10 万个元素的 List 始终得不到回收,因为 它始终被 subList 方法返回的 List 强引用。
- 解决方案:
data.add(new ArrayList<>(rawList.subList(0, 1)));
不强依赖原List
问题二:
private static void wrong() {
List<Integer> list = IntStream.rangeClosed(1, 10).boxed().collect(Collectors.toList());
List<Integer> subList = list.subList(1, 4);
System.out.println(subList);
subList.remove(1);
System.out.println(list);
list.add(0);
try {
subList.forEach(System.out::println);
} catch (Exception ex) {
ex.printStackTrace();
}
}我们通过源码分析可以知道,ArrayList.SubList的内部类,所有的操作都是操作它的parent(ArrayList),所以当我们直接修改了list,导致只是修改list的modCount,而sublist的modCount就少了一次,在所有操作之前都会判断modCount的大小,不然就会抛出
ConcurrentModificationException
异常当你操作subList的时候,因为是直接操作的List,所以会影响到list的数据
SubList 是原始 List 的视图。
源码分析:
private class SubList extends AbstractList<E> implements RandomAccess {
private final AbstractList<E> parent;
private final int parentOffset;
private final int offset;
int size;
//SubList是原始List的视图
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;
}
//每次插件modCount
private void checkForComodification() {
if (ArrayList.this.modCount != this.modCount)
throw new ConcurrentModificationException();
}
//本质就是操作parent的数据
public void add(int index, E e) {
rangeCheckForAdd(index);
checkForComodification();
parent.add(parentOffset + index, e);
this.modCount = parent.modCount;
this.size++;
}
//本质就是操作parent的数据
public E remove(int index) {
rangeCheck(index);
checkForComodification();
E result = parent.remove(parentOffset + index);
this.modCount = parent.modCount;
this.size--;
return result;
}
}
解决方案:
List<Integer> list = IntStream.rangeClosed(1, 10).boxed().collect(Collectors.toList());
//新的ArrayList
List<Integer> subList = new ArrayList<>(list.subList(1, 4));
//java8 stream操作
List<Integer> subList = list.stream().skip(1).limit(3).collect(Collectors.toList());- 一种是,不直接使用 subList 方法返回的 SubList,而是重新使用 new ArrayList,在构 造方法传入 SubList,来构建一个独立的 ArrayList;
- 另一种是,对于 Java 8 使用 Stream 的 skip 和 limit API 来跳过流中的元素,以及限制 流中元素的个数,同样可以达到 SubList 切片的目的。
一定要让合适的数据结构做合适的事情
第一个误区是,使用数据结构不考虑平衡时间和空间**。
栗子源码:
//在100w个元素,进行1000次查询耗时(list和map的耗时)
20861992
72388672
StopWatch '': running time = 3402737445 ns
---------------------------------------------
ns % Task name
---------------------------------------------
2912953845 086% listSearch //2.9s
489783600 014% mapSearch //0.4s我们知道,搜索 ArrayList 的时间复杂度是 O(n),而 HashMap 的 get 操作的时间复杂度 是 O(1)。所以,要对大 List 进行单值搜索的话,可以考虑使用 HashMap,其中 Key 是 要搜索的值,Value 是原始对象,会比使用 ArrayList 有非常明显的性能优势。
本质也是空间换时间:List占用21MB,而HashMap占用70MB
即使我们要搜索的不是单值而是条件区间,也可以尝试使用 HashMap 来进行“搜索性能 优化”。如果你的条件区间是固定的话,可以提前把 HashMap 按照条件区间进行分组, Key 就是不同的区间。(todo)
第二个误区是,过于迷信教科书的大 O 时间复杂度。
ArrayList和LinkedList基于连续存储的数组和基于指针串联的链表(双向)两种方式
- 对于数组,随机元素访问的时间复杂度是 O(1),元素插入操作是 O(n);
- 对于链表,随机元素访问的时间复杂度是 O(n),元素插入操作是 O(1)。
问题:那么,在大量的元素插入、很少的随机访问的业务场景下,是不是就应该使用 LinkedList 呢?
通过实验和查看源码我们可以知道:
/**
* LinkedList的源码分析:
* 在linkedList中的add操作,有三种:默认的add,只有一个参数,总是插到双向链表的尾部
* 带两个参数的add,链表需要要通过O(n)的时间复杂度找到index,在index后面利用双向链表,直接index
* 第三种:addFirst利用双向链表,总是插在头部
*/
/**
* Inserts the specified element at the specified position in this list.
* Shifts the element currently at that position (if any) and any
* subsequent elements to the right (adds one to their indices).
*
* @param index index at which the specified element is to be inserted
* @param element element to be inserted
* @throws IndexOutOfBoundsException {@inheritDoc}
*/
public void add(int index, E element) {
checkPositionIndex(index);
if (index == size)
linkLast(element);
else
linkBefore(element, node(index));
}
/**
* Links e as last element.
*/
void linkLast(E e) {
final Node<E> l = last;
final Node<E> newNode = new Node<>(l, e, null);
last = newNode;
if (l == null)
first = newNode;
else
l.next = newNode;
size++;
modCount++;
}- 记录一个有趣的事情:LinkedList 的作者约书亚 · 布洛克(Josh Bloch),曾经说过
Dose anyone actually use LinkedList? I wrote it,and I never use it
- 结论:在各种常用场景下,LinkedList 几乎都不能在性能上胜出 ArrayList。抛开算法层面不谈,由于 CPU 缓存、内存连续性等问题,链表这种数 据结构的实现方式对性能并不友好,即使在它最擅长的场景都不一定可以发挥威力。
- 记录一个有趣的事情:LinkedList 的作者约书亚 · 布洛克(Josh Bloch),曾经说过
重点回顾
- 第一,想当然认为,Arrays.asList 和 List.subList 得到的 List 是普通的、独立的 ArrayList,在使用时出现各种奇怪的问题。
- 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 一定可以把所有数组转换为正确的 List。当传入基本类 型数组的时候,List 的元素是数组本身,而不是数组中的元素。
- 第三,想当然认为,内存中任何集合的搜索都是很快的,结果在搜索超大 ArrayList 的时候 遇到性能问题。我们考虑利用 HashMap 哈希表随机查找的时间复杂度为 O(1) 这个特性来 优化性能,不过也要考虑 HashMap 存储空间上的代价,要平衡时间和空间。
- 第四,想当然认为,链表适合元素增删的场景,选用 LinkedList 作为数据结构。在真实场 景中读写增删一般是平衡的,而且增删不可能只是对头尾对象进行操作,可能在 90% 的情 况下都得不到性能增益,建议使用之前通过性能测试评估一下。
空值处理:分不清楚的null和恼人的空指针
程序中的变量是 null,就意味着它没有引用指向或者说没有指针。这时,我们对这个变量 进行任何操作,都必然会引发空指针异常,在 Java 中就是 NullPointerException。
修复和定位恼人的空指针问题
NullPointerException 是 Java 代码中最常见的异常,我将其最可能出现的场景归为以下
5 种:
- 参数值是 Integer 等包装类型,使用时因为自动拆箱出现了空指针异常;
- 字符串比较出现空指针异常;
- 诸如 ConcurrentHashMap 这样的容器不支持 Key 和 Value 为 null,强行 put null 的 Key 或 Value 会出现空指针异常;
- A 对象包含了 B,在通过 A 对象的字段获得 B 之后,没有对字段判空就级联调用 B 的方 法出现空指针异常;
- 方法或远程服务返回的 List 不是空而是 null,没有进行判空就直接调用 List 的方法出现 空指针异常。
实验栗子源码:模拟上面见到空指针的场景
public int wrong( { String test)
return wrongMethod(test.charAt(0) == '1' ? null : new FooService(),
test.charAt(1) == '1' ? null : 1,
test.charAt(2) == '1' ? null : "OK",
test.charAt(3) == '1' ? null : "OK").size();
}
private List<String> wrongMethod(FooService fooService, Integer i, String s, String t) {
log.info("result {} {} {} {}", i + 1, s.equals("OK"), s.equals(t),
new ConcurrentHashMap<String, String>().put(null, null));
if (fooService.getBarService().bar().equals("OK"))
log.info("OK");
return null;
}- requestParam参数是一个由 0 和 1 构成的、长度为 4 的字符串,第几位设置为 1 就代表第几个参数为 null,用来控制 wrongMethod 方法的 4 个入参。
- 四处异常
- 对入参 Integer i 进行 +1 操作;
- 对入参 String s 进行比较操作,判断内容是否等于”OK”;
- 对入参 String s 和入参 String t 进行比较操作,判断两者是否相等;
- 对 new 出来的 ConcurrentHashMap 进行 put 操作,Key 和 Value 都设置为 null。
java线上定位问题:Arthas神器
运行命令
查看方法的入参
watch org.geekbang.time.commonmistakes.nullvalue.avoidnullpointerexception.AvoidNullPointerExceptionController wrongMethod params
ts=2020-04-27 07:56:23; [cost=1.857199ms] result=@Object[][
null,
null,
@String[OK],
null,
]
stack 命令来查看 wrongMethod 方法的调用栈
stack org.geekbang.time.commonmistakes.nullvalue.avoidnullpointerexception.AvoidNullPointerExceptionController wrongMethod
-x 参数设置为 2 代表参数打印的深度为 2 层
watch org.geekbang.time.commonmistakes.nullvalue.avoidnullpointerexception.AvoidNullPointerExceptionController rightMethod params -x 2
ts=2020-04-27 08:29:15; [cost=6.957267ms] result=@Object[][
@FooService[
barService=null,
this$0=@AvoidNullPointerExceptionController[org.geekbang.time.commonmistakes.nullvalue.avoidnullpointerexception.AvoidNullPointerExceptionController@49809275],
],
@Integer[1],
@String[OK],
@String[OK],
]
处理空指针-正确姿势
其实,对于任何空指针异常的处理,最直白的方式是先判空后操作。不过,这只能让异常不 再出现,我们还是要找到程序逻辑中出现的空指针究竟是来源于入参还是 Bug:
- 如果是来源于入参,还要进一步分析入参是否合理等;
- 如果是来源于 Bug,那空指针不一定是纯粹的程序 Bug,可能还涉及业务属性和接口调 用规范等。
因为是 Demo,所以我们只考虑纯粹的空指针判空这种修复方式。如果要先判空 后处理,大多数人会想到使用 if-else 代码块。但,这种方式既增加代码量又会降低易读 性,我们可以尝试利用 Java 8 的 Optional 类来消除这样的 if-else 逻辑,使用一行代码进 行判空和处理。
- 对于 Integer 的判空,可以使用 Optional.ofNullable 来构造一个 Optional,然后使用 orElse(0) 把 null 替换为默认值再进行 +1 操作。
- 对于 String 和字面量的比较,可以把字面量放在前面,比如”OK”.equals(s),这样即使 s 是 null 也不会出现空指针异常;而对于两个可能为 null 的字符串变量的 equals 比 较,可以使用 Objects.equals,它会做判空处理。
- 对于 ConcurrentHashMap,既然其 Key 和 Value 都不支持 null,修复方式就是不要 把 null 存进去。HashMap 的 Key 和 Value 可以存入 null,而 ConcurrentHashMap 看似是 HashMap 的线程安全版本,却不支持 null 值的 Key 和 Value,这是容易产生误 区的一个地方。
- 对于类似 fooService.getBarService().bar().equals(“OK”) 的级联调用,需要判空的 地方有很多,包括 fooService、getBarService() 方法的返回值,以及 bar 方法返回的 字符串。如果使用 if-else 来判空的话可能需要好几行代码,但使用 Optional 的话一行 代码就够了。
- 对于 rightMethod 返回的 List,由于不能确认其是否为 null,所以在调用 size 方法获 得列表大小之前,同样可以使用 Optional.ofNullable 包装一下返回值,然后通 过.orElse(Collections.emptyList()) 实现在 List 为 null 的时候获得一个空的 List,最后 再调用 size 方法。
正确处理空指针代码
private List<String> rightMethod(FooService fooService, Integer i, String s, String t) {
log.info("result {} {} {} {}", Optional.ofNullable(i).orElse(0) + 1, "OK".equals(s), Objects.equals(s, t), new HashMap<String, String>().put(null, null));
Optional.ofNullable(fooService)
.map(FooService::getBarService)
.filter(barService -> "OK".equals(barService.bar()))
.ifPresent(result -> log.info("OK"));
return new ArrayList<>();
}
public int right( { String test)
return Optional.ofNullable(rightMethod(test.charAt(0) == '1' ? null : new FooService(),
test.charAt(1) == '1' ? null : 1,
test.charAt(2) == '1' ? null : "OK",
test.charAt(3) == '1' ? null : "OK"))
.orElse(Collections.emptyList()).size();
}使用判空方式或 Optional 方式来避免出现空指针异常,不一定是解 决问题的最好方式,空指针没出现可能隐藏了更深的 Bug。
- 因此,解决空指针异常,还是 要真正 case by case(具体问题具体分析) 地定位分析案例,然后再去做判空处理,而处理时也并不只是判断非 空然后进行正常业务流程这么简单,同样需要考虑为空的时候是应该出异常、设默认值还是 记录日志等。
POJO 中属性的 null 到底代表了什么?
相比判空避免空指针异常,更容易出错的是 null 的定位问题。对程序来说,null 就是指针没有任何指向,而结合业务逻辑情况就复杂得多,我们需要考虑:
- DTO 中字段的 null 到底意味着什么?是客户端没有传给我们这个信息吗?
- 既然空指针问题很讨厌,那么 DTO 中的字段要设置默认值么?
- 如果数据库实体中的字段有 null,那么通过数据访问框架保存数据是否会覆盖数据库中 的既有数据?
问题栗子源码
public class User {
private Long id;
private String name;
private String nickname;
private Integer age;
private Date createDate = new Date();
}
public User wrong( { User user)
user.setNickname(String.format("guest%s", user.getName()));
return userRepository.save(user);
}
//curl -H "Content-Type:application/json" -X POST -d '{ "id":1, "name":null}' http://127.0.0.1:45678/pojonull/wrong- 调用方只希望重置用户名,但 age 也被设置为了 null;
- nickname 是用户类型加姓名,name 重置为 null 的话,访客用户的昵称应该是guest,而不是 guestnull,重现了文首提到的那个笑点;
- 创建时间时间也被更新了
解决方案
public class UserDto {
private Long id;
private Optional<String> name;
private Optional<Integer> age;
}
public class UserEntity {
private Long id;
private String name;
private String nickname;
private Integer age;
private Date createDate;
}
public UserEntity right( { UserDto user)
//使用dto字段,只更新指定字段
if (user == null || user.getId() == null)
throw new IllegalArgumentException("用户Id不能为空");
//为了能够使用@DynamicUpdate,只更新更改了字段。
UserEntity userEntity = userEntityRepository.findById(user.getId())
.orElseThrow(() -> new IllegalArgumentException("用户不存在"));
//利用了optional的特性,可以区分,客户端是没有传入该字段,还是传入null
if (user.getName() != null) {
userEntity.setName(user.getName().orElse(""));
}
userEntity.setNickname("guest" + userEntity.getName());
if (user.getAge() != null) {
userEntity.setAge(user.getAge().orElseThrow(() -> new IllegalArgumentException("年龄不能为空")));
}
return userEntityRepository.save(userEntity);
}- UserDto 中只保留 id、name 和 age 三个属性,且 name 和 age 使用 Optional 来包 装,以区分客户端不传数据还是故意传 null。
- 在 UserEntity 的字段上使用 @Column 注解,把数据库字段 name、nickname、age 和 createDate 都设置为 NOT NULL,并设置 createDate 的默认值为 CURRENT_TIMESTAMP,由数据库来生成创建时间。
- 使用 Hibernate 的 @DynamicUpdate 注解实现更新 SQL 的动态生成,实现只更新修 改后的字段,不过需要先查询一次实体,让 Hibernate 可以“跟踪”实体属性的当前状 态,以确保有效。
定义POJO需要注意的五个问题
- 明确 DTO 种 null 的含义。对于 JSON 到 DTO 的反序列化过程,null 的表达是有歧义 的,客户端不传某个属性,或者传 null,这个属性在 DTO 中都是 null。
- 但,对于用户 信息更新操作,不传意味着客户端不需要更新这个属性,维持数据库原先的值;
- 传了 null,意味着客户端希望重置这个属性。
- 因为 Java 中的 null 就是没有这个数据,无法区 分这两种表达,所以本例中的 age 属性也被设置为了 null,或许我们可以借助 Optional 来解决这个问题。
- POJO 中的字段有默认值。如果客户端不传值,就会赋值为默认值,导致创建时间也被 更新到了数据库中。
- 注意字符串格式化时可能会把 null 值格式化为 null 字符串。
String.format("guest%s", user.getName())
- DTO 和 Entity 共用了一个 POJO。对于用户昵称的设置是程序控制的,我们不应该把 它们暴露在 DTO 中,否则很容易把客户端随意设置的值更新到数据库中。此外,创建时 间最好让数据库设置为当前时间,不用程序控制,可以通过在字段上设置 columnDefinition 来实现。
- 数据库字段允许保存 null,会进一步增加出错的可能性和复杂度。因为如果数据真正落 地的时候也支持 NULL 的话,可能就有 NULL、空字符串和字符串 null 三种状态。
小心 MySQL 中有关 NULL 的三个坑
数据库表字段允许存 NULL 除了会让我们困惑外,还容易有坑。这里我会结合NULL 字段,和你着重说明 sum 函数、count 函数,以及 NULL 值条件可能踩的坑。
问题源码分析:
//定义一张表,表示score字段允许为NULL,并且插入一条记录
public class User {
private Long id;
private Long score;
}
//error sql
//right sql- 通过 sum 函数统计一个只有 NULL 值的列的总和,比如 SUM(score);
- select 记录数量,count 使用一个允许 NULL 的字段,比如 COUNT(score);
- 使用 =NULL 条件查询字段值为 NULL 的记录,比如 score=null 条件。
期望和原因
- 虽然记录的 score 都是 NULL,但 sum 的结果应该是 0 才对;
- MySQL 中 sum 函数没统计到任何记录时,会返回 null 而不是 0,可以使用 IFNULL 函数把 null 转换为 0;
- 虽然这条记录的 score 是 NULL,但记录总数应该是 1 才对;
- MySQL 中 count 字段不统计 null 值,COUNT(*) 才是统计所有记录数量的正确方 式。
- 使用 =NULL 并没有查询到 id=1 的记录,查询条件失效。
- MySQL 中 =NULL 并不是判断条件而是赋值,对 NULL 进行判断只能使用 IS NULL 或 者 IS NOT NULL。
- 虽然记录的 score 都是 NULL,但 sum 的结果应该是 0 才对;
异常处理:别让自己在出问题的时候变为瞎子
应用程序避免不了出异常,捕获和处理异常是考验编程功力的一个精细活。一些业务项目 中,我曾看到开发同学在开发业务逻辑时不考虑任何异常处理,项目接近完成时再采用“流 水线”的方式进行异常处理,也就是统一为所有方法打上 try…catch…捕获所有异常记录日 志,有些技巧的同学可能会使用 AOP 来进行类似的“统一异常处理”。(都不可取)
捕获和处理异常容易犯的错
第一个错:”统一异常处理”,不在业务代码层面考虑异常处理,仅在框架 层面粗犷捕获和处理异常。
每层架构的工作性质不同,且从业务性质上异常可能分为业务异常和系统异常两大类,这就 决定了很难进行统一的异常处理。我们从底向上看一下三层架构:
- Repository 层出现异常或许可以忽略,或许可以降级,或许需要转化为一个友好的异 常。如果一律捕获异常仅记录日志,很可能业务逻辑已经出错,而用户和程序本身完全 感知不到。
- Service 层往往涉及数据库事务,出现异常同样不适合捕获,否则事务无法自动回滚。此 外 Service 层涉及业务逻辑,有些业务逻辑执行中遇到业务异常,可能需要在异常后转 入分支业务流程。如果业务异常都被框架捕获了,业务功能就会不正常。
- 如果下层异常上升到 Controller 层还是无法处理的话,Controller 层往往会给予用户友 好提示,或是根据每一个 API 的异常表返回指定的异常类型,同样无法对所有异常一视 同仁。
因此,我不建议在框架层面进行异常的自动、统一处理,尤其不要随意捕获异常。但,框架 可以做兜底工作。如果异常上升到最上层逻辑还是无法处理的话,可以以统一的方式进行异 常转换,比如通过 @RestControllerAdvice + @ExceptionHandler,来捕获这些“未处 理”异常:
对于自定义的业务异常,以 Warn 级别的日志记录异常以及当前 URL、执行方法等信息 后,提取异常中的错误码和消息等信息,转换为合适的 API 包装体返回给 API 调用方;
对于无法处理的系统异常,以 Error 级别的日志记录异常和上下文信息(比如 URL、参 数、用户 ID)后,转换为普适的“服务器忙,请稍后再试”异常信息,同样以 API 包装 体返回给调用方。
源码实现
public class RestControllerExceptionHandler {
private static int GENERIC_SERVER_ERROR_CODE = 2000;
private static String GENERIC_SERVER_ERROR_MESSAGE = "服务器忙,请稍后再试";
public APIResponse handle(HttpServletRequest req, HandlerMethod method, Exception ex) {
if (ex instanceof BusinessException) {
BusinessException exception = (BusinessException) ex;
log.warn(String.format("访问 %s -> %s 出现业务异常!", req.getRequestURI(), method.toString()), ex);
return new APIResponse(false, null, exception.getCode(), exception.getMessage());
} else {
//兜底工作
log.error(String.format("访问 %s -> %s 出现系统异常!", req.getRequestURI(), method.toString()), ex);
return new APIResponse(false, null, GENERIC_SERVER_ERROR_CODE, GENERIC_SERVER_ERROR_MESSAGE);
}
}
}
第二个错,捕获了异常后直接生吞。
- 在任何时候,我们捕获了异常都不应该生吞,也就是直 接丢弃异常不记录、不抛出。这样的处理方式还不如不捕获异常,因为被生吞掉的异常一旦 导致 Bug,就很难在程序中找到蛛丝马迹,使得 Bug 排查工作难上加难。
- 通常情况下,生吞异常的原因,可能是不希望自己的方法抛出受检异常,只是为了把异 常“处理掉”而捕获并生吞异常,也可能是想当然地认为异常并不重要或不可能产生。但不 管是什么原因,不管是你认为多么不重要的异常,都不应该生吞,哪怕是一个日志也好。
第三个错,丢弃异常的原始信息。
第四个错,抛出异常时不指定任何消息。
总之,如果你捕获了异常打算处理的话,除了通过日志正确记录异常原始信息外,通常还有 三种处理模式:
- 转换,即转换新的异常抛出。对于新抛出的异常,最好具有特定的分类和明确的异常消 息,而不是随便抛一个无关或没有任何信息的异常,并最好通过 cause 关联老异常。
- 重试,即重试之前的操作。比如远程调用服务端过载超时的情况,盲目重试会让问题更 严重,需要考虑当前情况是否适合重试。
- 恢复,即尝试进行降级处理,或使用默认值来替代原始数据。
小心 finally 中的异常
虽然 try 中的逻辑出现了异常,但却被 finally 中的异常覆盖了。
栗子源码:finally的异常覆盖try异常
public void wrong() {
try {
log.info("try");
throw new RuntimeException("try");
} finally {
log.info("finally");
throw new RuntimeException("finally");
}
}
public void right() {
try {
log.info("try");
throw new RuntimeException("try");
} finally {
log.info("finally");
try {
throw new RuntimeException("finally");
} catch (Exception ex) {
log.error("finally", ex);
}
}
}
public void right2() throws Exception {
Exception e = null;
try {
log.info("try");
throw new RuntimeException("try");
} catch (Exception ex) {
e = ex;
} finally {
log.info("finally");
try {
throw new RuntimeException("finally");
} catch (Exception ex) {
if (e != null) {
e.addSuppressed(ex);
} else {
e = ex;
}
}
}
throw e;
}- 方案一:fianlly的异常,finally自己捕获和处理
- 方案二: try 中的异常作为主异常抛出,使用 addSuppressed 方法把 finally 中的异常 附加到主异常上
try-with-resources来处理资源关闭的正确姿势:
implements AutoCloseable
public class TestResource implements AutoCloseable { |
- 实现
implements AutoCloseable
接口 - 本质就是,使用 addSuppressed 方法把 finally 中的异常 附加到主异常上
千万别把异常定义为静态变量
既然我们通常会自定义一个业务异常类型,来包含更多的异常信息,比如异常错误码、友好 的错误提示等,那就需要在业务逻辑各处,手动抛出各种业务异常来返回指定的错误码描述
最终定位到原因是把异常定义为了静态变量,导致异常栈信息错,也就是大家为什么经常说异常的定位问题不精确的问题,是自己异常定义的问题。而不是jdk的问题。
- 把异常定义为静态变量会导致异常信息固化,这就和异常的栈一定是需要根据当前调用来动 态获取相矛盾。
正确定义业务异常姿势
//错误:导致异常的堆栈信息紊乱
public static BusinessException ORDEREXISTS = new BusinessException("订单已经存在", 3001);
//正确姿势:通过不同的方法把每一种异常都 new 出 来抛出即可:
public static BusinessException orderExists() {
return new BusinessException("订单已经存在", 3001);
}
提交线程池的任务出了异常会怎么样?
线程池常用作异步处理或并行处理。那么,把任务提交 到线程池处理,任务本身出现异常时会怎样呢?
栗子源码:execute执行
static {
//todo:作用范围多大
Thread.setDefaultUncaughtExceptionHandler((thread, throwable) -> log.error("Thread {} got exception", thread, throwable));
}
public void execute() throws InterruptedException {
String prefix = "test";
ExecutorService threadPool = Executors.newFixedThreadPool(1, new ThreadFactoryBuilder()
.setNameFormat(prefix + "%d")
.setUncaughtExceptionHandler((thread, throwable) -> log.error("ThreadPool {} got exception", thread, throwable))
.get());
IntStream.rangeClosed(1, 10).forEach(i -> threadPool.execute(() -> {
if (i == 5) throw new RuntimeException("error");
log.info("I'm done : {}", i);
}));
threadPool.shutdown();
threadPool.awaitTermination(1, TimeUnit.HOURS);
}
//输出结果
[08:04:40.258] [test0] [INFO ] [o.g.t.c.e.t.ThreadPoolAndExceptionController:36 ] - I'm done : 1
[08:04:40.260] [test0] [INFO ] [o.g.t.c.e.t.ThreadPoolAndExceptionController:36 ] - I'm done : 2
[08:04:40.260] [test0] [INFO ] [o.g.t.c.e.t.ThreadPoolAndExceptionController:36 ] - I'm done : 3
[08:04:40.260] [test0] [INFO ] [o.g.t.c.e.t.ThreadPoolAndExceptionController:36 ] - I'm done : 4
//发生异常切换了线程
[08:04:40.261] [test1] [INFO ] [o.g.t.c.e.t.ThreadPoolAndExceptionController:36 ] - I'm done : 6
[08:04:40.261] [test1] [INFO ] [o.g.t.c.e.t.ThreadPoolAndExceptionController:36 ] - I'm done : 7
[08:04:40.261] [test1] [INFO ] [o.g.t.c.e.t.ThreadPoolAndExceptionController:36 ] - I'm done : 8
[08:04:40.262] [test1] [INFO ] [o.g.t.c.e.t.ThreadPoolAndExceptionController:36 ] - I'm done : 9
[08:04:40.262] [test1] [INFO ] [o.g.t.c.e.t.ThreadPoolAndExceptionController:36 ] - I'm done : 10
[08:04:40.267] [test0] [ERROR] [o.g.t.c.e.t.ThreadPoolAndExceptionController:32 ] - ThreadPool Thread[test0,5,main] got exception
java.lang.RuntimeException: error
at org.geekbang.time.commonmistakes.exception.threadpoolandexception.ThreadPoolAndExceptionController.lambda$null$2(ThreadPoolAndExceptionController.java:35)
at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1142)
at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:617)
at java.lang.Thread.run(Thread.java:745)- 任务 1 到 4 所在的线程是 test0,任务 6 开始运行在线程 test1。由于我的线程池通过 线程工厂为线程使用统一的前缀 test 加上计数器进行命名,因此从线程名的改变可以知 道因为异常的抛出老线程退出了,线程池只能重新创建一个线程。如果每个异步任务都 以异常结束,那么线程池可能完全起不到线程重用的作用。
因为没有手动捕获异常进行处理,ThreadGroup 帮我们进行了未捕获异常的默认处理, 向标准错误输出打印了出现异常的线程名称和异常信息。显然,这种没有以统一的错误 日志格式记录错误信息打印出来的形式,对生产级代码是不合适的,ThreadGroup 的相 关源码如下所示:
public void uncaughtException(Thread t, Throwable e) {
if (parent != null) {
parent.uncaughtException(t, e);
} else {
Thread.UncaughtExceptionHandler ueh =
Thread.getDefaultUncaughtExceptionHandler();
if (ueh != null) {
ueh.uncaughtException(t, e);
} else if (!(e instanceof ThreadDeath)) {
System.err.print("Exception in thread \""
+ t.getName() + "\" ");
e.printStackTrace(System.err);
}
}
}定义线程池处理异常的正确姿势
以 execute 方法提交到线程池的异步任务,最好在任务内部做好异常处理;
设置自定义的异常处理程序作为保底,比如在声明线程池时自定义线程池的未捕获异常 处理程序:
Thread.setDefaultUncaughtExceptionHandler
,对@Async生效static {
//设置默认线程池异步执行任务出错执行的逻辑(全局)
Thread.setDefaultUncaughtExceptionHandler((thread, throwable) -> log.error("Thread {} got exception", thread, throwable));
}
ExecutorService threadPool = Executors.newFixedThreadPool(1, new ThreadFactoryBuilder()
.setNameFormat(prefix + "%d")
.setUncaughtExceptionHandler((thread, throwable) -> log.error("ThreadPool {} got exception", thread, throwable))
.get());
栗子源码:submit执行
public void submitRight() throws InterruptedException {
String prefix = "test";
ExecutorService threadPool = Executors.newFixedThreadPool(1, new ThreadFactoryBuilder().setNameFormat(prefix + "%d").get());
List<Future> tasks = IntStream.rangeClosed(1, 10).mapToObj(i -> threadPool.submit(() -> {
if (i == 5) throw new RuntimeException("error");
log.info("I'm done : {}", i);
})).collect(Collectors.toList());
tasks.forEach(task -> {
try {
task.get();
} catch (Exception e) {
log.error("Got exception", e);
}
});
threadPool.shutdown();
threadPool.awaitTermination(1, TimeUnit.HOURS);
}我们执行submit就是关心执行结果,当我们没有到FutureTask.get的时候,是没有异常信息打印的。把执行过程的异常封装为
ExecutionException
源码分析
/**
* Causes this future to report an {@link ExecutionException}
* with the given throwable as its cause, unless this future has
* already been set or has been cancelled.
*
* <p>This method is invoked internally by the {@link #run} method
* upon failure of the computation.
*
* @param t the cause of failure
*/
protected void setException(Throwable t) {
if (UNSAFE.compareAndSwapInt(this, stateOffset, NEW, COMPLETING)) {
outcome = t;
UNSAFE.putOrderedInt(this, stateOffset, EXCEPTIONAL); // final state
finishCompletion();
}
}
public void run() {
if (state != NEW ||
!UNSAFE.compareAndSwapObject(this, runnerOffset,
null, Thread.currentThread()))
return;
try {
Callable<V> c = callable;
if (c != null && state == NEW) {
V result;
boolean ran;
try {
result = c.call();
ran = true;
} catch (Throwable ex) {
result = null;
ran = false;
setException(ex);
}
if (ran)
set(result);
}
} finally {
// runner must be non-null until state is settled to
// prevent concurrent calls to run()
runner = null;
// state must be re-read after nulling runner to prevent
// leaked interrupts
int s = state;
if (s >= INTERRUPTING)
handlePossibleCancellationInterrupt(s);
}
}
日志:日志记录真没你想象的那么简单
- 日志框架众多,不同的类库可能会使用不同的日志框架,如何兼容是一个问题。配置复杂且容易出错。日志配置文件通常很复杂,因此有些开发同学会从其他项目或者 网络上复制一份配置文件,但却不知道如何修改,甚至是胡乱修改,造成很多问题。比 如,重复记录日志的问题、同步日志的性能问题、异步记录的错误配置问题。
- Logback、Log4j、Log4j2、commons-logging、JDK 自带的 java.util.logging 等,都 是 Java 体系的日志框架,确实非常多。而不同的类库,还可能选择使用不同的日志框架。 这样一来,日志的统一管理就变得非常困难。为了解决这个问题,就有了 SLF4J(Simple Logging Facade For Java)
- 一是提供了统一的日志门面 API,即图中紫色部分,实现了中立的日志记录 API。
- 二是桥接功能,即图中蓝色部分,用来把各种日志框架的 API(图中绿色部分)桥接到 SLF4J API。这样一来,即便你的程序中使用了各种日志 API 记录日志,最终都可以桥接 到 SLF4J 门面 API。
- 三是适配功能,即图中红色部分,可以实现 SLF4J API 和实际日志框架(图中灰色部 分)的绑定。SLF4J 只是日志标准,我们还是需要一个实际的日志框架。日志框架本身 没有实现 SLF4J API,所以需要有一个前置转换。Logback 就是按照 SLF4J API 标准实 现的,因此不需要绑定模块做转换。
- 需要理清楚的是,虽然我们可以使用 log4j-over-slf4j 来实现 Log4j 桥接到 SLF4J,也可 以使用 slf4j-log4j12 实现 SLF4J 适配到 Log4j,也把它们画到了一列,但是它不能同时使 用它们,否则就会产生死循环。jcl 和 jul 也是同样的道理。
为什么我的日志会重复记录?
用几个栗子复习日志(Logback)配置,第一个案例是,logger 配置继承关系导致日志重复记录。
OFF、FATAL、ERROR、WARN、INFO、DEBUG、ALL(日志级别从高到底)
栗子源码
<?xml version="1.0" encoding="UTF-8" ?>
<configuration>
<!--首先将 CONSOLE Appender 定义为 ConsoleAppender,也就是把日志 输出到控制台(System.out/System.err);然后通过 PatternLayout 定义了日志的输 出格式。-->
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
<layout class="ch.qos.logback.classic.PatternLayout">
<pattern>[%d{yyyy-MM-dd HH:mm:ss.SSS}] [%thread] [%-5level] [%logger{40}:%line] - %msg%n</pattern>
</layout>
</appender>
<!-- 实现了一个 Logger 配置,将应用包的日志级别设置为 DEBUG、日志输出 同样使用 CONSOLE Appender。-->
<logger name="org.geekbang.time.commonmistakes.logging" level="DEBUG">
<appender-ref ref="CONSOLE"/>
</logger>
<!--设置了全局的日志级别为 INFO,日志输出使用 CONSOLE Appender-->
<root level="INFO">
<appender-ref ref="CONSOLE"/>
</root>
</configuration>
<!-- 配置的初衷是实现自定义的 logger 配置,让应用内的日志暂 时开启 DEBUG 级别的日志记录。-->
<logger name="org.geekbang.time.commonmistakes.logging" level="DEBUG"/>
public void log() {
log.debug("debug");
log.info("info");
log.warn("warn");
log.error("error");
}CONSOLE 这个 Appender 同时挂载到了两个 Logger 上,一个是我们定义的,一个是,由于我们定义的继承自root,所以同一条日志既会通 过 logger 记录,也会发送到 root 记录,因此应用 package 下的日志出现了重复记录。
如果自定义的需要把日志输出到不同的 Appender,比如将应用的日志输出到文件 app.log,把其他框架的日志输出到控制台,可以设置的 additivity 属性为 false,这样就 不会继承的 Appender 了。
<appender name="FILE" class="ch.qos.logback.core.FileAppender">
<file>app.log</file>
<encoder class="ch.qos.logback.classic.encoder.PatternLayoutEncoder">
<pattern>[%d{yyyy-MM-dd HH:mm:ss.SSS}] [%thread] [%-5level] [%logger{40}:%line] - %msg%n</pattern>
</encoder>
</appender>
<logger name="org.geekbang.time.commonmistakes.logging" level="DEBUG" additivity="false">
<appender-ref ref="FILE"/>
</logger>
第二个案例是,错误配置 LevelFilter 造成日志重复记录。
错误配置分析:在记录日志到控制台的同时,把日志记录按照不同的级别记 录到两个文件中
<configuration>
<property name="logDir" value="./logs"/>
<property name="app.name" value="common-mistakes"/>
<!-- 第一个 ConsoleAppender,用于把所有日志输出到控制台-->
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
<layout class="ch.qos.logback.classic.PatternLayout">
<pattern>[%d{yyyy-MM-dd HH:mm:ss.SSS}] [%thread] [%-5level] [%logger{40}:%line] - %msg%n</pattern>
</layout>
</appender>
<appender name="INFO_FILE" class="ch.qos.logback.core.FileAppender">
<!-- 定义了一个 FileAppender,用于记录文件日志,并定义了文件名、记录 日志的格式和编码等信息-->
<File>${logDir}/${app.name}_info.log</File>
<!-- LevelFilter 过滤日志, 将过滤级别设置为 INFO,目的是希望 _info.log 文件中可以记录 INFO 级别的日志-->
<filter class="ch.qos.logback.classic.filter.LevelFilter">
<level>INFO</level>
</filter>
<encoder class="ch.qos.logback.classic.encoder.PatternLayoutEncoder">
<pattern>[%d{yyyy-MM-dd HH:mm:ss.SSS}] [%thread] [%-5level] [%logger{40}:%line] - %msg%n</pattern>
<charset>UTF-8</charset>
</encoder>
</appender>
<appender name="ERROR_FILE" class="ch.qos.logback.core.FileAppender">
<File>${logDir}/${app.name}_error.log</File>
<!-- 使用 ThresholdFilter 来过滤日 志,过滤级别设置为 WARN,目的是把 WARN 以上级别的日志记录到另一个 _error.log 文件中-->
<filter class="ch.qos.logback.classic.filter.ThresholdFilter">
<level>WARN</level>
</filter>
<encoder class="ch.qos.logback.classic.encoder.PatternLayoutEncoder">
<pattern>[%d{yyyy-MM-dd HH:mm:ss.SSS}] [%thread] [%-5level] [%logger{40}:%line] - %msg%n</pattern>
<charset>UTF-8</charset>
</encoder>
</appender>
<!-- 定义的 root 引用了三个 Appender-->
<root level="INFO">
<appender-ref ref="CONSOLE"/>
<appender-ref ref="INFO_FILE"/>
<appender-ref ref="ERROR_FILE"/>
</root>
</configuration>但是上面的配置,没有按照我们的期望执行,在info文件包括了:info、warn、error的日志。
问题解决:增加配置项。
<appender name="INFO_FILE" class="ch.qos.logback.core.FileAppender">
<File>${logDir}/${app.name}_info.log</File>
<filter class="ch.qos.logback.classic.filter.LevelFilter">
<level>INFO</level>
<!--解决方案:只接受INFO级别的日志-->
<onMatch>ACCEPT</onMatch>
<onMismatch>DENY</onMismatch>
</filter>
<encoder class="ch.qos.logback.classic.encoder.PatternLayoutEncoder">
<pattern>[%d{yyyy-MM-dd HH:mm:ss.SSS}] [%thread] [%-5level] [%logger{40}:%line] - %msg%n</pattern>
<charset>UTF-8</charset>
</encoder>
</appender>源码分析:复习一下 ThresholdFilter 和 LevelFilter 的配置方式
public class ThresholdFilter extends Filter<ILoggingEvent> {
Level level;
public FilterReply decide(ILoggingEvent event) {
if (!isStarted()) {
return FilterReply.NEUTRAL;
}
if (event.getLevel().isGreaterOrEqual(level)) {
return FilterReply.NEUTRAL;
} else {
return FilterReply.DENY;
}
}
}
public class LevelFilter extends AbstractMatcherFilter<ILoggingEvent> {
Level level;
public FilterReply decide(ILoggingEvent event) {
if (!isStarted()) {
return FilterReply.NEUTRAL;
}
if (event.getLevel().equals(level)) {
return onMatch;
} else {
return onMismatch;
}
}
}
//自定义:LevelsFilter
public class MultipleLevelsFilter extends Filter<ILoggingEvent> {
private String levels;
private List<Integer> levelList;
public FilterReply decide(ILoggingEvent event) {
if (levelList == null && !StringUtils.isEmpty(levels)) {
levelList = Arrays.asList(levels.split("\\|")).stream()
.map(item -> Level.valueOf(item))
.map(level -> level.toInt())
.collect(Collectors.toList());
}
if (levelList.contains(event.getLevel().toInt()))
return FilterReply.ACCEPT;
else
return FilterReply.DENY;
}
}和 ThresholdFilter 不同的是,LevelFilter 仅仅配置 level 是无法真正起作用的。由于没有 配置 onMatch 和 onMismatch 属性,所以相当于这个过滤器是无用的,导致 INFO 以 上级别的日志都记录了。
使用异步日志改善性能的坑
模拟实验标准:记录1000次日志,每次记录1MB记录大小的字符串。和记录10000 次日志的耗时。分别是9s和44s,耗时比较长。
栗子源码
public void performance(int count) {
long begin = System.currentTimeMillis();
//生成1.9MB大小的字符串
String payload = IntStream.rangeClosed(1, 1000000)
.mapToObj(__ -> "a")
.collect(Collectors.joining("")) + UUID.randomUUID().toString();
//for循环次数写入文件
IntStream.rangeClosed(1, count).forEach(i -> log.info("{} {}", i, payload));
// EvaluatorFilter(求值过滤器)
Marker timeMarker = MarkerFactory.getMarker("time");
log.info(timeMarker, "took {} ms", System.currentTimeMillis() - begin);
}
//配置文件
<appender name="FILE" class="ch.qos.logback.core.FileAppender">
<file>app.log</file>
<encoder class="ch.qos.logback.classic.encoder.PatternLayoutEncoder">
<pattern>[%d{yyyy-MM-dd HH:mm:ss.SSS}] [%thread] [%-5level] [%logger{40}:%line] - %msg%n</pattern>
</encoder>
</appender>
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
<layout class="ch.qos.logback.classic.PatternLayout">
<pattern>[%d{yyyy-MM-dd HH:mm:ss.SSS}] [%thread] [%-5level] [%logger{40}:%line] - %msg%n</pattern>
</layout>
<filter class="ch.qos.logback.core.filter.EvaluatorFilter">
<!-- EvaluatorFilter(求值过滤器) -->
<evaluator class="ch.qos.logback.classic.boolex.OnMarkerEvaluator">
<marker>time</marker>
</evaluator>
<onMismatch>DENY</onMismatch>
<onMatch>ACCEPT</onMatch>
</filter>
</appender>
<root level="INFO">
<appender-ref ref="FILE"/>
<appender-ref ref="CONSOLE"/>
</root>源码分析
//在追加日志的时候,是直接把日志写入 OutputStream 中,属于同 步记录日志:
FileAppender<E> extends OutputStreamAppender<E> extends UnsynchronizedAppenderBase<E>
protected void subAppend(E event) {
if (!isStarted()) {
return;
}
try {
// this step avoids LBCLASSIC-139
if (event instanceof DeferredProcessingAware) {
((DeferredProcessingAware) event).prepareForDeferredProcessing();
}
// the synchronization prevents the OutputStream from being closed while we
// are writing. It also prevents multiple threads from entering the same
// converter. Converters assume that they are in a synchronized block.
// lock.lock();
//编码LoggingEvent
byte[] byteArray = this.encoder.encode(event);
//写字节流
writeBytes(byteArray);
} catch (IOException ioe) {
// as soon as an exception occurs, move to non-started state
// and add a single ErrorStatus to the SM.
this.started = false;
addStatus(new ErrorStatus("IO failure in appender", this, ioe));
}
}
private void writeBytes(byte[] byteArray) throws IOException {
if(byteArray == null || byteArray.length == 0)
return;
lock.lock();
try {
//这个OutputStream其实是一个ResilientFileOutputStream,其内部使用的是带缓存
this.outputStream.write(byteArray);
if (immediateFlush) {
this.outputStream.flush();
}
} finally {
lock.unlock();
}
}解决方案:使用 Logback 提供的 AsyncAppender 即可实现异步的日志记录。 AsyncAppende 类似装饰模式,也就是在不改变类原有基本功能的情况下为其增添新功 能。这样,我们就可以把 AsyncAppender 附加在其他的 Appender 上,将其变为异步 的。
<appender name="ASYNCFILE" class="ch.qos.logback.classic.AsyncAppender">
<appender-ref ref="FILE"/>
</appender>测试一下可以发现,记录 1000 次日志和 10000 次日志的调用耗时,分别是 735 毫秒和 668 毫秒。性能居然那么好,会有坑吗?
遇到过很多关于 AsyncAppender 异步日志的 坑,这些坑可以归结为三类:
- 记录异步日志撑爆内存;
- 记录异步日志出现日志丢失;
- 记录异步日志出现阻塞。
栗子源码:模拟AsyncAppender异步记录日志,日志丢失的问题
<?xml version="1.0" encoding="UTF-8" ?>
<configuration>
<appender name="CONSOLE" class="org.geekbang.time.commonmistakes.logging.async.MySlowAppender">
<layout class="ch.qos.logback.classic.PatternLayout">
<pattern>[%d{yyyy-MM-dd HH:mm:ss.SSS}] [%thread] [%-5level] [%logger{40}:%line] - %msg%n</pattern>
</layout>
</appender>
<appender name="ASYNC" class="ch.qos.logback.classic.AsyncAppender">
<appender-ref ref="CONSOLE" />
<includeCallerData>true</includeCallerData>
<!-- <discardingThreshold>200</discardingThreshold>-->
<!-- <queueSize>1000</queueSize>-->
<!-- <neverBlock>true</neverBlock>-->
</appender>
<root level="INFO">
<appender-ref ref="ASYNC" />
</root>
</configuration>
//重写ConsoleAppender,模拟写入慢日志
public class MySlowAppender extends ConsoleAppender {
protected void subAppend(Object event) {
try {
// 模拟慢日志
TimeUnit.MILLISECONDS.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
super.subAppend(event);
}
}循环写了1000次日志,但是最后写入成功的只有215条记录。
源码分析:AsyncAppender 提供了一些配置参数,而我们没用对。我们结 合相关源码分析一下
public class AsyncAppender extends AsyncAppenderBase<ILoggingEvent> {
boolean includeCallerData = false;//是否收集调用方数据
/**
* Events of level TRACE, DEBUG and INFO are deemed to be discardable.
* @param event
* @return true if the event is of level TRACE, DEBUG or INFO false otherwise.
*/
protected boolean isDiscardable(ILoggingEvent event) {
Level level = event.getLevel();
return level.toInt() <= Level.INFO_INT;
}
protected void preprocess(ILoggingEvent eventObject) {
eventObject.prepareForDeferredProcessing();
if (includeCallerData)
eventObject.getCallerData();//丢弃<=INFO级别的日志
}
public boolean isIncludeCallerData() {
return includeCallerData;
}
public void setIncludeCallerData(boolean includeCallerData) {
this.includeCallerData = includeCallerData;
}
}
public class AsyncAppenderBase<E> extends UnsynchronizedAppenderBase<E> implements AppenderAttachable<E> {
BlockingQueue<E> blockingQueue;//异步日志的关键,阻塞队列
/**
* The default buffer size.
*/
public static final int DEFAULT_QUEUE_SIZE = 256;//默认队列大小
int queueSize = DEFAULT_QUEUE_SIZE;
int appenderCount = 0;
static final int UNDEFINED = -1;
int discardingThreshold = UNDEFINED;
boolean neverBlock = false;//控制队列满的时候加入数据时是否直接丢弃,不会阻塞等待
public void start() {
if (isStarted())
return;
if (appenderCount == 0) {
addError("No attached appenders found.");
return;
}
if (queueSize < 1) {
addError("Invalid queue size [" + queueSize + "]");
return;
}
blockingQueue = new ArrayBlockingQueue<E>(queueSize);
if (discardingThreshold == UNDEFINED)
discardingThreshold = queueSize / 5;//默认丢弃阈值是队列剩余量低于队列长度(剩余日志条数)
addInfo("Setting discardingThreshold to " + discardingThreshold);
worker.setDaemon(true);
worker.setName("AsyncAppender-Worker-" + getName());
// make sure this instance is marked as "started" before staring the worker Thread
super.start();
worker.start();
}
private void put(E eventObject) {
if (neverBlock) {
blockingQueue.offer(eventObject);
} else {
putUninterruptibly(eventObject);
}
}
protected void append(E eventObject) {
//阻塞队列还剩下多少的时候丢弃&&丢弃日志级别
if (isQueueBelowDiscardingThreshold() && isDiscardable(eventObject)) {
return;
}
preprocess(eventObject);
put(eventObject);
}
}- includeCallerData 用于控制是否收集调用方数据,默认是 false,此时方法行号、方法 名等信息将不能显示()。
- queueSize 用于控制阻塞队列大小,使用的 ArrayBlockingQueue 阻塞队列(),默认大小是 256,即内存中最多保存 256 条日志。
- discardingThreshold 是控制丢弃日志的阈值,主要是防止队列满后阻塞。默认情况 下,队列剩余量低于队列长度的 20%,就会丢弃 TRACE、DEBUG 和 INFO 级别的日 志。
- neverBlock 用于控制队列满的时候,加入的数据是否直接丢弃,不会阻塞等待,默认是 false(允许阻塞)。这里需要注意一下 offer 方法和 put 方法的区别,当队列 满的时候 offer 方法不阻塞,而 put 方法会阻塞;neverBlock 为 true 时,使用 offer 方法。
看到默认队列大小为 256,达到 80% 容量后开始丢弃 <=INFO 级别的日志后,我们就可 以理解日志中为什么只有 215 条 INFO 日志了。
我们可以继续分析下异步记录日志出现坑的原因。
- queueSize 设置得特别大,就可能会导致 OOM。
- queueSize 设置得比较小(默认值就非常小),且 discardingThreshold 设置为大于 0 的值(或者为默认值),队列剩余容量少于 discardingThreshold 的配置就会丢弃 <=INFO 的日志。这里的坑点有两个。一是,因为 discardingThreshold 的存在,设置 queueSize 时容易踩坑。比如,本例中最大日志并发是 1000,即便设置 queueSize 为 1000 同样会导致日志丢失。二是,discardingThreshold 参数容易有歧义,它不是百分 比,而是日志条数。对于总容量 10000 的队列,如果希望队列剩余容量少于 1000 条的 时候丢弃,需要配置为 1000。
- neverBlock 默认为 false,意味着总可能会出现阻塞。如果 discardingThreshold 为 0,那么队列满时再有日志写入就会阻塞;如果 discardingThreshold 不为 0,也只会丢 弃 <=INFO 级别的日志,那么出现大量错误日志时,还是会阻塞程序。
可以看出 queueSize、discardingThreshold 和 neverBlock 这三个参数息息相关,务必 按需进行设置和取舍,到底是性能为先,还是数据不丢为先:
- 如果考虑绝对性能为先,那就设置 neverBlock 为 true,永不阻塞。
- 如果考虑绝对不丢数据为先,那就设置 discardingThreshold 为 0,即使是 <=INFO 的 级别日志也不会丢,但最好把 queueSize 设置大一点,毕竟默认的 queueSize 显然太 小,太容易阻塞。
- 如果希望兼顾两者,可以丢弃不重要的日志,把 queueSize 设置大一点,再设置一个合 理的 discardingThreshold。
使用日志占位符就不需要进行日志级别判断了?
不知道你有没有听人说过:SLF4J 的{}占位符语法,到真正记录日志时才会获取实际参数, 因此解决了日志数据获取的性能问题。你觉得,这种说法对吗?(错误说法)
栗子源码
//细节注解
public class LoggingController {
/**
* SLF4J 的{}占位符语法,到真正记录日志时才会获取实际参数, 因此解决了日志数据获取的性能问题。错误说法
*/
public void index() {
StopWatch stopWatch = new StopWatch();
//拼接字符串方式记录 slowString;
stopWatch.start("debug1");
log.debug("debug1:" + slowString("debug1"));
stopWatch.stop();
//使用占位符方式记录 slowString;
stopWatch.start("debug2");
log.debug("debug2:{}", slowString("debug2"));
stopWatch.stop();
//先判断日志级别是否启用 DEBUG。
stopWatch.start("debug3");
if (log.isDebugEnabled())
log.debug("debug3:{}", slowString("debug3"));
stopWatch.stop();
//最佳实践
stopWatch.start("debug4");
log.debug("debug4:{}", () -> slowString("debug4"));
stopWatch.stop();
log.info(stopWatch.prettyPrint());
}
private String slowString(String s) {
System.out.println("slowString called via " + s);
try {
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
}
return "OK";
}
}
//实验结果
slowString called via debug1
slowString called via debug2
[18:11:38.517] [http-nio-45678-exec-2] [INFO ] [o.g.t.c.l.placeholder.LoggingController:32 ] - StopWatch '': running time = 2008376517 ns
---------------------------------------------
ns % Task name
---------------------------------------------
1004357006 050% debug1
1004003087 050% debug2
000002588 000% debug3
000013836 000% debug4- 如果我们记录 DEBUG 日志,并设置只记录 >=INFO 级别的日志,程序是否也会耗时 1 秒 呢?我们使用三种方法来测试。
- 使用占位符方式记录 slowString 的方式,同样需要耗时 1 秒,是因为这种方式虽然允许我 们传入 Object,不用拼接字符串,但也只是延迟(如果日志不记录那么就是省去)了日志 参数对象.toString() 和字符串拼接的耗时。
- 除非事先判断日志级别,否则必然会调用 slowString 方法。回到之前提的 问题,使用{}占位符语法不能通过延迟参数值获取,来解决日志数据获取的性能问题。
- 除了事先判断日志级别,我们还可以通过 lambda 表达式进行延迟参数内容获取。但, SLF4J 的 API 还不支持 lambda,因此需要使用 Log4j2 日志 API,把 Lombok 的 @Slf4j 注解替换为 @Log4j2 注解,这样就可以提供一个 lambda 表达式作为提供参数数据的方 法:
- 其实,我们只是换成了 Log4j2 API,真正的日志记录还是走的 Logback 框架。没错,这 就是 SLF4J 适配的一个好处。
重点回顾
我将记录日志的坑,总结为框架使用配置和记录本身两个方面。
- Java 的日志框架众多,SLF4J 实现了这些框架记录日志的统一。在使用 SLF4J 时,我们需 要理清楚其桥接 API 和绑定这两个模块。如果程序启动时出现 SLF4J 的错误提示,那很可 能是配置出现了问题,可以使用 Maven 的 dependency:tree 命令梳理依赖关系。
- Logback 是 Java 最常用的日志框架,其配置比较复杂,你可以参考官方文档中关于 Appender、Layout、Filter 的配置,切记不要随意从其他地方复制别人的配置,避免出现 错误或与当前需求不符。
- 使用异步日志解决性能问题,是用空间换时间。但空间毕竟有限,当空间满了之后,我们要 考虑是阻塞等待,还是丢弃日志。如果更希望不丢弃重要日志,那么选择阻塞等待;如果更 希望程序不要因为日志记录而阻塞,那么就需要丢弃日志。
- 最后,我强调的是,日志框架提供的参数化日志记录方式不能完全取代日志级别的判断。如 果你的日志量很大,获取日志参数代价也很大,就要进行相应日志级别的判断,避免不记录 日志也要花费时间获取日志参数的问题。
文件IO:实现高效正确的文件读写并非易事
随着数据库系统的成熟和普及,需要直接做文件 IO 操作的需求越来越少,这就导致我们对 相关 API 不够熟悉,以至于遇到类似文件导出、三方文件对账等需求时,只能临时抱佛 脚,随意搜索一些代码完成需求,出现性能问题或者 Bug 后不知从何处入手。
今天这篇文章,我就会从字符编码、缓冲区和文件句柄释放这 3 个常见问题出发,和你分享如何解决与文件操作相关的性能问题或者 Bug。
文件读写需要确保字符编码一致
一份相同的代码,在两台机器出现乱码的问题如何解决
栗子源码
/**
* 使用 GBK 编码把“你好 hi”写入一个名为 hello.txt 的文本文件,
* 然后直接以字节数组形式读取文件内容,转换为十六进制字符串输出到日志
*
* @throws IOException
*/
private static void init() throws IOException {
Files.deleteIfExists(Paths.get("hello.txt"));
Files.write(Paths.get("hello.txt"), "你好hi".getBytes(Charset.forName("GBK")));
log.info("bytes:{}", Hex.encodeHexString(Files.readAllBytes(Paths.get("hello.txt"))).toUpperCase());
}
private static void wrong() throws IOException {
//读取机器的默认编码格式
log.info("charset: {}", Charset.defaultCharset());
char[] chars = new char[10];
String content = "";
//FileReader 是以当前机器的默认字符集来读取文件的
try (FileReader fileReader = new FileReader("hello.txt")) {
int count;
while ((count = fileReader.read(chars)) != -1) {
content += new String(chars, 0, count);
}
}
//文件格式是GBK,使用UTF-8采用字符集操作,导致乱码
log.info("result:{}", content);
//UTF-8 编码 的“你好”的十六进制是 E4BDA0E5A5BD,每一个汉字需要三个字节;而 GBK 编码的汉 字,每一个汉字两个字节。
Files.write(Paths.get("hello2.txt"), "你好hi".getBytes(Charsets.UTF_8));
log.info("bytes:{}", Hex.encodeHexString(Files.readAllBytes(Paths.get("hello2.txt"))).toUpperCase());
}
/**
* 正确姿势:读取GBK写入的字符。
* 最佳实践:FileReader 是以当前机器的默认字符集来读取文件的。
* 按照文档所说,直接使用 FileInputStream 拿文件流, 然后使用 InputStreamReader 读取字符流,并指定字符集为 GBK。
* @throws IOException
*/
private static void right1() throws IOException {
char[] chars = new char[10];
String content = "";
try (FileInputStream fileInputStream = new FileInputStream("hello.txt");
InputStreamReader inputStreamReader = new InputStreamReader(fileInputStream, Charset.forName("GBK"))) {
int count;
while ((count = inputStreamReader.read(chars)) != -1) {
content += new String(chars, 0, count);
}
}
log.info("result: {}", content);
}
/**
* 正确姿势读取GBK字符02
*
* @throws IOException
*/
private static void right2() throws IOException {
log.info("result: {}", Files.readAllLines(Paths.get("hello.txt"), Charset.forName("GBK")).stream().findFirst().orElse(""));
}
虽然我们打开文本文件时看到的是“你好 hi”,但不管是什么文字,计算机中都是按照一 定的规则将其以二进制保存的。
- 这个规则就是字符集,字符集枚举了所有支持的字符映射成 二进制的映射表。
- 在处理文件读写的时候,如果是在字节层面进行操作,那么不会涉及字符 编码问题;而如果需要在字符层面进行读写的话,就需要明确字符的编码方式也就是字符集 了。
最佳实践:FileReader 是以当前机器的默认字符集来读取文件的。按照文档所说,直接使用 FileInputStream 拿文件流, 然后使用 InputStreamReader 读取字符流,并指定字符集为 GBK。
java.nio.file.Files#readAllLines(java.nio.file.Path, java.nio.charset.Charset)
但这种方式有个问题是,读取超出内存大小的大文件时会出现 OOM源码分析public static List<String> readAllLines(Path path, Charset cs) throws IOException {
try (BufferedReader reader = newBufferedReader(path, cs)) {
//把文件读取的所有内容都读取到List中
List<String> result = new ArrayList<>();
for (;;) {
String line = reader.readLine();
if (line == null)
break;
result.add(line);
}
return result;
}
}- 解决方案,按需读取,而不是一次性读取所有内容。解决方案就是 Files 类的 lines 方法
使用 Files 类静态方法进行文件操作注意释放文件句柄
栗子源码:读取一个5G文件大小的行数,证明Files.lines是一行行处理
private static void readLargeFileWrong() throws IOException {
log.info("lines {}", Files.readAllLines(Paths.get("large.txt")).size());
}
private static void readLargeFileRight() throws IOException {
AtomicLong atomicLong = new AtomicLong();
Files.lines(Paths.get("large.txt")).forEach(line -> atomicLong.incrementAndGet());
log.info("lines {}", atomicLong.get());
}- 上面代码优化点:问题在于读取完文件后没有关闭。我们通常会认为静态方法的调用不涉及资源释放,因为方 法调用结束自然代表资源使用完成,由 API 释放资源,但对于 Files 类的一些返回 Stream 的方法并不是这样。这,是一个很容易被忽略的严重问题。
句柄栗子源码
private static void wrong() {
//ps aux | grep CommonMistakesApplication
//lsof -p 63937
//lsof -p 63937 | grep demo.txt | wc -l
LongAdder longAdder = new LongAdder();
IntStream.rangeClosed(1, 1000000).forEach(i -> {
try {
Thread.sleep(5000);
Files.lines(Paths.get("demo.txt")).forEach(line -> longAdder.increment());
} catch (IOException e) {
e.printStackTrace();
} catch (InterruptedException e) {
e.printStackTrace();
}
});
log.info("total : {}", longAdder.longValue());
}
//正确读写文件的正确姿势
private static void right() {
//https://docs.oracle.com/javase/8/docs/api/java/nio/file/Files.html
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());
}
- 程序在生产上运行一段时间后就会出现 too many files 的错误, 我们想当然地认为是 OS 设置的最大文件句柄太小了,就让运维放开这个限制,但放开后还 是会出现这样的问题。经排查发现,其实是文件句柄没有释放导致的,问题就出在 Files.lines 方法上。
lsof -p 63937 | grep demo.txt | wc -l
查看打开了1w多的文件,句柄被消耗完了。- 其实,在JDK 文档中有提到,注意使用 try-with-resources 方式来配合,确保流的 close 方法可以调用释放资源。
- 这也很容易理解,使用流式处理,如果不显式地告诉程序什么时候用完了流,程序又如何知 道呢,它也不能帮我们做主何时关闭文件。
源码分析:try-with-resources来处理资源关闭的正确姿势:
implements AutoCloseable
public static Stream<String> lines(Path path, Charset cs) throws IOException {
BufferedReader br = Files.newBufferedReader(path, cs);
try {
return br.lines().onClose(asUncheckedRunnable(br));
} catch (Error|RuntimeException e) {
try {
br.close();
} catch (IOException ex) {
try {
e.addSuppressed(ex);
} catch (Throwable ignore) {}
}
throw e;
}
}
private static Runnable asUncheckedRunnable(Closeable c) {
return () -> {
try {
c.close();
} catch (IOException e) {
throw new UncheckedIOException(e);
}
};
}
public interface Closeable extends AutoCloseable {
/**
* Closes this stream and releases any system resources associated
* with it. If the stream is already closed then invoking this
* method has no effect.
*
* <p> As noted in {@link AutoCloseable#close()}, cases where the
* close may fail require careful attention. It is strongly advised
* to relinquish the underlying resources and to internally
* <em>mark</em> the {@code Closeable} as closed, prior to throwing
* the {@code IOException}.
*
* @throws IOException if an I/O error occurs
*/
public void close() throws IOException;
}- 查看 lines 方法源码可以发现,Stream 的 close 注册了一个回调,来关闭 BufferedReader 进行资源释放。
BufferedReader ,从命名上可以看出,使用 BufferedReader 进行字符流读取时,用到了缓冲。这里缓冲 Buffer 的意思是,使用一块内存区域作为直接操作的中转。
- 读取文件操作就是一次性读取一大块数据(比如 8KB)到缓冲区,后续的读取可以 直接从缓冲区返回数据,而不是每次都直接对应文件 IO。
- 写操作也是类似。如果每次写几 十字节到文件都对应一次 IO 操作,那么写一个几百兆的大文件可能就需要千万次的 IO 操 作,耗时会非常久。
try-with-resources语法糖
源码分析
public static void main(String[] args) throws IOException {
//try-with-resouces
try (NoahResource nr = new NoahResource()) {
nr.read(null);
}
}
public static void main(String[] args) throws IOException {
NoahResource nr = new NoahResource();
Throwable var2 = null;
try {
nr.read((String)null);
} catch (Throwable var11) {
var2 = var11;
throw var11;
} finally {
if (nr != null) {
if (var2 != null) {
try {
nr.close();
} catch (Throwable var10) {
var2.addSuppressed(var10);
}
} else {
nr.close();
}
}
}
}
注意读写文件要考虑设置缓冲区
场景:开发人员写的文件处理代码大概是这样的:使用 FileInputStream 获得一个文件输入 流,然后调用其 read 方法每次读取一个字节,最后通过一个 FileOutputStream 文件输出 流把处理后的结果写入另一个文件。
栗子源码
/**
* 创建一个文件随机写入 100 万行数据,文件大小在 35MB 左 右:
*/
private static void init() throws IOException {
Files.write(Paths.get("src.txt"),
IntStream.rangeClosed(1, 1000000).mapToObj(i -> UUID.randomUUID().toString()).collect(Collectors.toList())
, UTF_8, CREATE, TRUNCATE_EXISTING);
}
/**
* 读一个字节,写入一个字节
* <p>
* 使用 FileInputStream 获得一个文件输入 流,然后调用其 read 方法每次读取一个字节,最后通过一个 FileOutputStream 文件输出 流把处理后的结果写入另一个文件
* <p>
* 显然,每读取一个字节、每写入一个字节都进行一次 IO 操作,代价太大了。
* 复制一个 35MB 的文件居然耗时 190 秒
*
* @throws IOException
*/
private static void perByteOperation() throws IOException {
Files.deleteIfExists(Paths.get("dest.txt"));
try (FileInputStream fileInputStream = new FileInputStream("src.txt");
FileOutputStream fileOutputStream = new FileOutputStream("dest.txt")) {
int i;
while ((i = fileInputStream.read()) != -1) {
fileOutputStream.write(i);
}
}
}
/**
* 使用缓存区
* 改良后,使用 100 字节作为缓冲区,使用 FileInputStream 的 byte[]的重载来一次性读取 一定字节的数据,
* 同时使用 FileOutputStream 的 byte[]的重载实现一次性从缓冲区写入 一定字节的数据到文件:
*
* @throws IOException
*/
private static void bufferOperationWith100Buffer() throws IOException {
Files.deleteIfExists(Paths.get("dest.txt"));
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);
}
}
}- 显然,每读取一个字节、每写入一个字节都进行一次 IO 操作,代价太大了
- 使用Buffer缓存区。仅仅使用了 100 个字节的缓冲区作为过渡,完成 35M 文件的复制耗时缩短到了 26 秒,是 无缓冲时性能的 7 倍。
- 实现文件读写还要自己 new 一个缓冲区出来,太麻烦了,不是有一个 BufferedInputStream 和 BufferedOutputStream 可以实现输入输出流的缓冲处理吗?是的,它们在内部实现了一个默认 8KB 大小的缓冲区。但是,在使用 BufferedInputStream 和 BufferedOutputStream 时,
- 我还是建议你再使用一个缓冲进行 读写,不要因为它们实现了内部缓冲就进行逐字节的操作。
关于读写文件Buffered最佳实践栗子源码
/**
* 使用BufferedInputStream和BufferedOutputStream
*
* @throws IOException
*/
private static void bufferedStreamByteOperation() throws IOException {
Files.deleteIfExists(Paths.get("dest.txt"));
try (BufferedInputStream bufferedInputStream = new BufferedInputStream(new FileInputStream("src.txt"));
BufferedOutputStream bufferedOutputStream = new BufferedOutputStream(new FileOutputStream("dest.txt"))) {
int i;
while ((i = bufferedInputStream.read()) != -1) {
bufferedOutputStream.write(i);
}
}
}
/**
* 额外使用一个8KB缓冲,再使用BufferedInputStream和BufferedOutputStream
*
* @throws IOException
*/
private static void bufferedStreamBufferOperation() throws IOException {
Files.deleteIfExists(Paths.get("dest.txt"));
try (BufferedInputStream bufferedInputStream = new BufferedInputStream(new FileInputStream("src.txt"));
BufferedOutputStream bufferedOutputStream = new BufferedOutputStream(new FileOutputStream("dest.txt"))) {
byte[] buffer = new byte[8192];
int len = 0;
while ((len = bufferedInputStream.read(buffer)) != -1) {
bufferedOutputStream.write(buffer, 0, len);
}
}
}
/**
* 直接使用FileInputStream和FileOutputStream,再使用一个8KB的缓冲
*
* @throws IOException
*/
private static void largerBufferOperation() throws IOException {
Files.deleteIfExists(Paths.get("dest.txt"));
try (FileInputStream fileInputStream = new FileInputStream("src.txt");
FileOutputStream fileOutputStream = new FileOutputStream("dest.txt")) {
byte[] buffer = new byte[8192];
int len = 0;
while ((len = fileInputStream.read(buffer)) != -1) {
fileOutputStream.write(buffer, 0, len);
}
}
}- Buffer三种读写方式性能测试:
- 直接使用 BufferedInputStream 和 BufferedOutputStream;
- 额外使用一个 8KB 缓冲,使用 BufferedInputStream 和 BufferedOutputStream;
- 直接使用 FileInputStream 和 FileOutputStream,再使用一个 8KB 的缓冲。
- 第一种(bufferedStreamByteOperation)方式虽然使用了缓冲流,但逐字节的操作因为方法调用次数实在太多还是 慢,耗时 1.4 秒。后面两种方式的性能差不多,耗时 110 毫秒左右。虽然第三种方式没有 使用缓冲流,但使用了 8KB 大小的缓冲区,和缓冲流默认的缓冲区大小相同。
- 看到这里,你可能会疑惑了,既然这样使用 BufferedInputStream 和 BufferedOutputStream 有什么意义呢?
- 其实,这里我是为了演示所以示例三使用了固定大小的缓冲区,但在实际代码中每次需要读 取的字节数很可能不是固定的,有的时候读取几个字节,有的时候读取几百字节,这个时候 有一个固定大小较大的缓冲,也就是使用 BufferedInputStream 和 BufferedOutputStream 做为后备的稳定的二次缓冲,就非常有意义了。
- Buffer三种读写方式性能测试:
最后我要补充说明的是,对于类似的文件复制操作,如果希望有更高性能,可以使用 FileChannel 的 transfreTo 方法进行流的复制。在一些操作系统(比如高版本的 Linux 和 UNIX)上可以实现 DMA(直接内存访问),也就是数据从磁盘经过总线直接发送到目标 文件,无需经过内存和 CPU 进行数据中转。
栗子源码
/**
* 最高效的文件复制:走总线发送给目标,不走内存和cpu
*
* @throws IOException
*/
private static void fileChannelOperation() throws IOException {
Files.deleteIfExists(Paths.get("dest.txt"));
FileChannel in = FileChannel.open(Paths.get("src.txt"), StandardOpenOption.READ);
FileChannel out = FileChannel.open(Paths.get("dest.txt"), CREATE, WRITE);
in.transferTo(0, in.size(), out);
}
重点回顾
- 第一,如果需要读写字符流,那么需要确保文件中字符的字符集和字符流的字符集是一致 的,否则可能产生乱码。
- 第二,使用 Files 类的一些流式处理操作,注意使用 try-with-resources 包装 Stream,确 保底层文件资源可以释放,避免产生 too many open files 的问题。
- 第三,进行文件字节流操作的时候,一般情况下不考虑进行逐字节操作,使用缓冲区进行批 量读写减少 IO 次数,性能会好很多。一般可以考虑直接使用缓冲输入输出流 BufferedXXXStream,追求极限性能的话可以考虑使用 FileChannel 进行流转发。
- 最后我要强调的是,文件操作因为涉及操作系统和文件系统的实现,JDK 并不能确保所有 IO API 在所有平台的逻辑一致性,代码迁移到新的操作系统或文件系统时,要重新进行功 能测试和性能测试。
Java8科普篇一
Lambda 表达式
Lambda 表达式的初衷是,进一步简化匿名类的语法(不过实现上,Lambda 表达式并不 是匿名类的语法糖),使 Java 走向函数式编程。对于匿名类,虽然没有类名,但还是要给出方法定义。
public void lambdavsanonymousclass() {
//匿名类
new Thread(new Runnable() {
public void run() {
System.out.println("hello1");
}
}).start();
//lambda表达式
new Thread(() -> System.out.println("hello2")).start();
}那么,Lambda 表达式如何匹配 Java 的类型系统呢? 答案=函数式接口(java.util.function 包中定义了各种函数式接口)。
函数式接口是一种只有单一抽象方法的接口,使用 @FunctionalInterface 来描述,可以隐 式地转换成 Lambda 表达式。使用 Lambda 表达式来实现函数式接口,不需要提供类名和 方法定义,通过一行代码提供函数式接口的实例,就可以让函数成为程序中的头等公民,可 以像普通数据一样作为参数传递,而不是作为一个固定的类中的固定方法。
常见的函数式接口
public void functionalInterfaces() {
//可以看一下java.util.function包
//用于提供数据的 Supplier 接口,就只有一个 get 抽象方法,没有任何入参、有一个返回值
Supplier<String> supplier = String::new;
Supplier<String> stringSupplier = () -> "OK";
//Predicate的例子:Predicate接口是输入一个参数,返回布尔值
Predicate<Integer> positiveNumber = i -> i > 0;
Predicate<Integer> evenNumber = i -> i % 2 == 0;
assertTrue(positiveNumber.and(evenNumber).test(2));
//Consumer的例子,输出两行abcdefg。Consumer接口是消费一个数据
Consumer<String> println = System.out::println;
println.andThen(println).accept("abcdefg");
//Function的例子,Function接口是输入一个数据,计算后输出一个数据。
Function<String, String> upperCase = String::toUpperCase;
Function<String, String> duplicate = s -> s.concat(s);
assertThat(upperCase.andThen(duplicate).apply("test"), is("TESTTEST"));
//Supplier的例子,Supplier是提供一个数据的接口
Supplier<Integer> random = () -> ThreadLocalRandom.current().nextInt();
System.out.println(random.get());
//BinaryOperator,BinaryOperator是输入两个同类型参数,输出一个同类型参数的接口
BinaryOperator<Integer> add = Integer::sum;
BinaryOperator<Integer> subtraction = (a, b) -> a - b;
assertThat(subtraction.apply(add.apply(1, 2), 3), is(0));
}
- java.util.function 包中定义了各种函数式接口
- Predicate接口是输入一个参数,返回布尔值
- Consumer接口是消费一个数据
- Function接口是输入一个数据,计算后输出一个数据。
- Supplier是提供一个数据的接口
- BinaryOperator是输入两个同类型参数,输出一个同类型参数的接口
Predicate、Function 等函数式接口,还使用 default 关键字实现了几个默认方法。这样一 来,它们既可以满足函数式接口只有一个抽象方法,又能为接口提供额外的功能。
很明显,Lambda 表达式给了我们复用代码的更多可能性:我们可以把一大段逻辑中变化 的部分抽象出函数式接口,由外部方法提供函数实现,重用方法内的整体逻辑处理
使用Java8简化代码
这一部分,我会通过几个具体的例子,带你感受一下使用 Java 8 简化代码的三个重要方面:
- 使用 Stream 简化集合操作;
- 使用 Optional 简化判空逻辑;
- JDK8 结合 Lambda 和 Stream 对各种类的增强。
计算x-y坐标的平均距离,栗子源码
/**
* 业务功能:普通实现
*
* 把整数列表转换为 Point2D 列表;
* 遍历 Point2D 列表过滤出 Y 轴 >1 的对象;
* 计算 Point2D 点到原点的距离;
* 累加所有计算出的距离,并计算距离的平均值。
*
* @param ints
* @return
*/
private static double calc(List<Integer> ints) {
//临时中间集合
List<Point2D> point2DList = new ArrayList<>();
for (Integer i : ints) {
point2DList.add(new Point2D.Double((double) i % 3, (double) i / 3));
}
//临时变量,纯粹是为了获得最后结果需要的中间变量
double total = 0;
int count = 0;
for (Point2D point2D : point2DList) {
//过滤
if (point2D.getY() > 1) {
//算距离
double distance = point2D.distance(0, 0);
total += distance;
count++;
}
}
return count > 0 ? total / count : 0;
}
/**
* 使用Java8 stream遍历集合
*
* map 方法传入的是一个 Function,可以实现对象转换;
* filter 方法传入一个 Predicate,实现对象的布尔判断,只保留返回 true 的数据;
* mapToDouble 用于把对象转换为 double;
* 通过 average 方法返回一个 OptionalDouble,代表可能包含值也可能不包含值的可空 double。
*/
public void stream() {
List<Integer> ints = Arrays.asList(1, 2, 3, 4, 5, 6, 7, 8);
double average = calc(ints);
double streamResult = ints.stream()
.map(i -> new Point2D.Double((double) i % 3, (double) i / 3))
.filter(point -> point.getY() > 1)
.mapToDouble(point -> point.distance(0, 0))
.average()
.orElse(0);
//如何用一行代码来实现,比较一下可读性
assertThat(average, is(streamResult));
}optional使用栗子
public void optional() {
//通过get方法获取Optional中的实际值
assertThat(Optional.of(1).get(), is(1));
//通过ofNullable来初始化一个null,通过orElse方法实现Optional中无数据的时候返回一个默认值A
assertThat(Optional.ofNullable(null).orElse("A"), is("A"));
//OptionalDouble是基本类型double的Optional对象,isPresent判断有无数据
assertFalse(OptionalDouble.empty().isPresent());
//通过map方法可以对Optional对象进行级联转换,不会出现空指针,转换后还是一个Optional
assertThat(Optional.of(1).map(Math::incrementExact).get(), is(2));
//通过filter实现Optional中数据的过滤,得到一个Optional,然后级联使用orElse提供默认值
assertThat(Optional.of(1).filter(integer -> integer % 2 == 0).orElse(null), is(nullValue()));
//通过orElseThrow实现无数据时抛出异常
Optional.empty().orElseThrow(IllegalArgumentException::new);
}
Optional方法图解
源码位置java.util.Optional
Java 8 类对于函数式 API 增强
栗子源码
private Product getProductAndCacheCool(Long id) {
//todo:实现value=null,也保存进去,缓存穿透,要看Map的具体实现,当前是ConrecntHashMap,看下HashMap
return cache.computeIfAbsent(id, i -> //当Key不存在的时候提供一个Function来代表根据Key获取Value的过程
Product.getData().stream()
.filter(p -> p.getId().equals(i)) //过滤
.findFirst() //找第一个,得到Optional<Product>
.orElse(null)); //如果找不Product到则使用null
}
//java.util.Map#computeIfAbsent,源码分析
default V computeIfAbsent(K key,
Function<? super K, ? extends V> mappingFunction) {
Objects.requireNonNull(mappingFunction);
V v;
if ((v = get(key)) == null) {
V newValue;
if ((newValue = mappingFunction.apply(key)) != null) {
put(key, newValue);
return newValue;
}
}
return v;
}
栗子源码
public void filesExample() throws IOException {
//无限深度,递归遍历文件夹
try (Stream<Path> pathStream = Files.walk(Paths.get("."))) {
pathStream.filter(Files::isRegularFile) //只查普通文件
.filter(FileSystems.getDefault().getPathMatcher("glob:**/*.java")::matches) //搜索java源码文件
.flatMap(ThrowingFunction.unchecked(path ->
Files.readAllLines(path).stream() //读取文件内容,转换为Stream<List>
.filter(line -> Pattern.compile("public class").matcher(line).find()) //使用正则过滤带有public class的行
.map(line -> path.getFileName() + " >> " + line))) //把这行文件内容转换为文件名+行
.forEach(System.out::println); //打印所有的行
}
}
//定义捕获受检异常转换为运行时异常的函数式接口
public interface ThrowingFunction<T, R, E extends Throwable> {
static <T, R, E extends Throwable> Function<T, R> unchecked(ThrowingFunction<T, R, E> f) {
return t -> {
try {
return f.apply(t);
} catch (Throwable e) {
throw new RuntimeException(e);
}
};
}
R apply(T t) throws E;
}
并行流和Java多线程实现
前面我们看到的 Stream 操作都是串行 Stream,操作只是在一个线程中执行,此外 Java 8 还提供了并行流的功能:通过 parallel 方法,一键把 Stream 转换为并行操作提交到线程 池处理。
栗子场景:为了实现多线程这五种实现方式,我们设计一个场景:使用 20 个线程(threadCount)以并行方 式总计执行 10000 次(taskCount)操作。因为单个任务单线程执行需要 10 毫秒(任务 代码如下),也就是每秒吞吐量是 100 个操作,那 20 个线程 QPS 是 2000,执行完 10000 次操作最少耗时 5 秒。(todo:谈谈你对QPS的理解,那线程越多,QPS越高?)
QPS理解
//线程数为20的时候的qps
---------------------------------------------
ns % Task name
---------------------------------------------
5683286850 019% thread
5608652892 019% threadpool
6119871101 021% stream
6088013378 021% forkjoin
6130607306 021% completableFuture
//线程数为40的时候的qps
---------------------------------------------
ns % Task name
---------------------------------------------
2972616257 019% thread
2884939811 019% threadpool
3111060456 020% stream
3184826327 021% forkjoin
3137196085 021% completableFuture
栗子源码
private void increment(AtomicInteger atomicInteger) {
atomicInteger.incrementAndGet();
try {
TimeUnit.MILLISECONDS.sleep(10);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
/**
* 第一种方式是使用线程。直接把任务按照线程数均匀分割,分配到不同的线程执行,使用 CountDownLatch 来阻塞主线程,直到所有线程都完成操作。
* 这种方式,需要我们自己分 割任务
*
* @param taskCount
* @param threadCount
* @return
* @throws InterruptedException
*/
private int thread(int taskCount, int threadCount) throws InterruptedException {
AtomicInteger atomicInteger = new AtomicInteger();
//size的大小是线程数的大小,而不是任务数的大小
CountDownLatch countDownLatch = new CountDownLatch(threadCount);
IntStream.rangeClosed(1, threadCount).mapToObj(i -> new Thread(() -> {
IntStream.rangeClosed(1, taskCount / threadCount).forEach(j -> increment(atomicInteger));
countDownLatch.countDown();
})).forEach(Thread::start);
countDownLatch.await();
return atomicInteger.get();
}
/**
* 第二种方式是,使用 Executors.newFixedThreadPool 来获得固定线程数的线程池,使用 execute 提交所有任务到线程池执行,最后关闭线程池等待所有任务执行完成:
*
* @param taskCount
* @param threadCount
* @return
* @throws InterruptedException
*/
private int threadpool(int taskCount, int threadCount) throws InterruptedException {
AtomicInteger atomicInteger = new AtomicInteger();
ExecutorService executorService = Executors.newFixedThreadPool(threadCount);
IntStream.rangeClosed(1, taskCount).forEach(i -> executorService.execute(() -> increment(atomicInteger)));
executorService.shutdown();
executorService.awaitTermination(1, TimeUnit.HOURS);
return atomicInteger.get();
}
/**
* 第三种方式是,使用 ForkJoinPool 而不是普通线程池执行任务。
* <p>
* ForkJoinPool 和传统的 ThreadPoolExecutor 区别在于,前者对于 n 并行度有 n 个独立 队列,后者是共享队列。
* 如果有大量执行耗时比较短的任务,ThreadPoolExecutor 的单队 列就可能会成为瓶颈。
* 这时,使用 ForkJoinPool 性能会更好。
*
* @param taskCount
* @param threadCount
* @return
* @throws InterruptedException
*/
private int forkjoin(int taskCount, int threadCount) throws InterruptedException {
AtomicInteger atomicInteger = new AtomicInteger();
//定义并行度
ForkJoinPool forkJoinPool = new ForkJoinPool(threadCount);
forkJoinPool.execute(() -> IntStream.rangeClosed(1, taskCount).parallel().forEach(i -> increment(atomicInteger)));
forkJoinPool.shutdown();
forkJoinPool.awaitTermination(1, TimeUnit.HOURS);
return atomicInteger.get();
}
/**
* 第四种方式是,直接使用并行流,并行流使用公共的 ForkJoinPool,也就是 ForkJoinPool.commonPool()。
*
* @param taskCount
* @param threadCount
* @return
*/
private int stream(int taskCount, int threadCount) {
//设置公共ForkJoinPool的并行度
System.setProperty("java.util.concurrent.ForkJoinPool.common.parallelism", String.valueOf(threadCount));
AtomicInteger atomicInteger = new AtomicInteger();
IntStream.rangeClosed(1, taskCount).parallel().forEach(i -> increment(atomicInteger));
return atomicInteger.get();
}
/**
* 第五种方式是,使用 CompletableFuture 来实现
*
* @param taskCount
* @param threadCount
* @return
* @throws InterruptedException
* @throws ExecutionException
*/
private int completableFuture(int taskCount, int threadCount) throws InterruptedException, ExecutionException {
AtomicInteger atomicInteger = new AtomicInteger();
ForkJoinPool forkJoinPool = new ForkJoinPool(threadCount);
CompletableFuture.runAsync(() -> IntStream.rangeClosed(1, taskCount).parallel().forEach(i -> increment(atomicInteger)), forkJoinPool).get();
return atomicInteger.get();
}理论科普
- 第一种方式是使用线程。直接把任务按照线程数均匀分割,分配到不同的线程执行,使用 CountDownLatch 来阻塞主线程,直到所有线程都完成操作
- 第二种方式是,使用 Executors.newFixedThreadPool 来获得固定线程数的线程池,使用 execute 提交所有任务到线程池执行,最后关闭线程池等待所有任务执行完成
- 第三种方式是,使用 ForkJoinPool 而不是普通线程池执行任务。
- ForkJoinPool 和传统的 ThreadPoolExecutor 区别在于,前者对于 n 并行度有 n 个独立 队列,后者是共享队列。如果有大量执行耗时比较短的任务,ThreadPoolExecutor 的单队 列就可能会成为瓶颈。这时,使用 ForkJoinPool 性能会更好
- 第四种方式是,直接使用并行流,并行流使用公共的 ForkJoinPool,也就是 ForkJoinPool.commonPool()。
- 公共的 ForkJoinPool 默认的并行度是 CPU 核心数 -1,原因是对于 CPU 绑定的任务分配 超过 CPU 个数的线程没有意义。由于并行流还会使用主线程执行任务,也会占用一个 CPU 核心,所以公共 ForkJoinPool 的并行度即使 -1 也能用满所有 CPU 核心
System.setProperty("java.util.concurrent.ForkJoinPool.common.parallelism", String.valueOf(threadCount));
- 第五种方式是,使用 CompletableFuture 来实现。
一般而 言,使用线程池(第二种)和直接使用并行流(第四种)的方式在业务代码中比较常用。另外需要注意的是,在上面的例子中我们一定是先运行 stream 方法再运行 forkjoin 方 法,对公共 ForkJoinPool 默认并行度的修改才能生效。因此我的建议是,设置 ForkJoinPool 公共线程池默认并行度的操作,应该放在应用 启动时设置。
Java8科普篇二
Stream操作详解
场景栗子
/** |
创建流
public class GenerateStreamTest { |
- 通过 stream 方法把 List 或数组转换为流;
- 通过 Stream.of 方法直接传入多个元素构成一个流;
- 通过 Stream.iterate 方法使用迭代的方式构造一个无限流,然后使用 limit 限制流元素个数
- 通过 Stream.generate 方法从外部传入一个提供元素的 Supplier 来构造无限流,然后 使用 limit 限制流元素个数;
- 通过 IntStream 或 DoubleStream 构造基本类型的流。
filter
|
map
|
flatMap
/** |
sorted
|
distinct
|
skip & limit
|
collect
collect 是收集操作,对流进行终结(终止)操作,把流导出为我们需要的数据结构。“终 结”是指,导出后,无法再串联使用其他中间操作,比如 filter、map、flatmap、 sorted、distinct、limit、skip。在 Stream 操作中,collect 是最复杂的终结操作,比较简单的终结操作还有 forEach、 toArray、min、max、count、anyMatch 等,我就不再展开了,你可以查询JDK 文 档,搜索 terminal operation 或 intermediate operation。
|
groupBy
nb强大的groupBy操作
|
partitioningBy
partitioningBy 用于分区,分区是特殊的分组,只有 true 和 false 两组。比如,我们把用 户按照是否下单进行分区,给 partitioningBy 方法传入一个 Predicate 作为数据分区的区 分,输出是 Map<Boolean, List>
|
连接池:别让连接池帮了倒忙
连接池的结构。连接池一般对外提供获得连接、归还连接的接口给客户端使 用,并暴露最小空闲连接数、最大连接数等可配置参数,在内部则实现连接建立、连接心跳保持、连接管理、空闲连接回收、连接可用性检测等功能。
注意鉴别客户端 SDK 是否基于连接池
在使用三方客户端进行网络通信时,我们首先要确定客户端 SDK 是否是基于连接池技术实现的。我们知道,TCP 是面向连接的基于字节流的协议:
- 面向连接,意味着连接需要先创建再使用,创建连接的三次握手有一定开销;
- 基于字节流,意味着字节是发送数据的最小单元,TCP 协议本身无法区分哪几个字节是 完整的消息体,也无法感知是否有多个客户端在使用同一个 TCP 连接,TCP 只是一个读 写数据的管道。
如果客户端 SDK 没有使用连接池,而直接是 TCP 连接,那么就需要考虑每次建立 TCP 连 接的开销,并且因为 TCP 基于字节流,在多线程的情况下对同一连接进行复用,可能会产 生线程安全问题。
我们先看一下涉及 TCP 连接的客户端 SDK,对外提供 API 的三种方式。在面对各种三方客 户端的时候,只有先识别出其属于哪一种,才能理清楚使用方式。
- 连接池和连接分离的 API:有一个 XXXPool 类负责连接池实现,先从其获得连接 XXXConnection,然后用获得的连接进行服务端请求,完成后使用者需要归还连接。通 常,XXXPool 是线程安全的,可以并发获取和归还连接,而 XXXConnection 是非线程 安全的。对应到连接池的结构示意图中,XXXPool 就是右边连接池那个框,左边的客户 端是我们自己的代码
- 内部带有连接池的 API:对外提供一个 XXXClient 类,通过这个类可以直接进行服务端 请求;这个类内部维护了连接池,SDK 使用者无需考虑连接的获取和归还问题。一般而 言,XXXClient 是线程安全的。对应到连接池的结构示意图中,整个 API 就是蓝色框包 裹的部分
- 非连接池的 API:一般命名为 XXXConnection,以区分其是基于连接池还是单连接的, 而不建议命名为 XXXClient 或直接是 XXX。直接连接方式的 API 基于单一连接,每次使 用都需要创建和断开连接,性能一般,且通常不是线程安全的。对应到连接池的结构示 意图中,这种形式相当于没有右边连接池那个框,客户端直接连接服务端创建连接
连接池SDK的最佳实践:
- 如果是分离方式,那么连接池本身一般是线程安全的,可以复用。每次使用需要从连接 池获取连接,使用后归还,归还的工作由使用者负责。
- 如果是内置连接池,SDK 会负责连接的获取和归还,使用的时候直接复用客户端。
- 如果 SDK 没有实现连接池(大多数中间件、数据库的客户端 SDK 都会支持连接池), 那通常不是线程安全的,而且短连接的方式性能不会很高,使用的时候需要考虑是否自 己封装一个连接池。
栗子源码:
private static JedisPool jedisPool = new JedisPool("127.0.0.1", 6379); |
执行程序多次,可以看到日志中出现了各种奇怪的异常信息,有的是读取 Key 为 b 的 Value 读取到了 1,有的是流非正常结束,还有的是连接关闭异常。
Jedis源码分析
public class Jedis extends BinaryJedis implements JedisCommands, MultiKeyCommands, |
可以看到,Jedis 继承了 BinaryJedis,BinaryJedis 中保存了单个 Client 的实例,Client 最终继承了 Connection,Connection 中保存了单个 Socket 的实例,和 Socket 对应的 两个读写流。因此,一个 Jedis 对应一个 Socket 连接
BinaryClient 封装了各种 Redis 命令,其最终会调用基类 Connection 的方法,使用 Protocol 类发送命令。看一下 Protocol 类的 sendCommand 方法的源码,可以发现其发 送命令时是直接操作 RedisOutputStream 写入字节。
我们在多线程环境下复用 Jedis 对象,其实就是在复用 RedisOutputStream。如果多个线 程在执行操作,那么既无法确保整条命令以一个原子操作写入 Socket,也无法确保写入 后、读取前没有其他数据写到远端:
Jedis最佳实践&解决问题:
修复方式是,使用 Jedis 提供的另一个线程安全的类 JedisPool 来获得 Jedis 的实例。
JedisPool 可以声明为 static 在多个线程之间共享,扮演连接池的角色。使用时,按需使用 try-with-resources 模式从 JedisPool 获得和归还 Jedis 实例。
public void right() throws InterruptedException {
new Thread(() -> {
try (Jedis jedis = jedisPool.getResource()) {
for (int i = 0; i < 1000; i++) {
String result = jedis.get("a");
if (!"1".equals(result)) {
log.warn("Expect a to be 1 but found {}", result);
return;
}
}
}
}).start();
new Thread(() -> {
try (Jedis jedis = jedisPool.getResource()) {
for (int i = 0; i < 1000; i++) {
String result = jedis.get("b");
if (!"2".equals(result)) {
log.warn("Expect b to be 2 but found {}", result);
return;
}
}
}
}).start();
TimeUnit.SECONDS.sleep(5);
}
线程池归还连接&非线程池关闭TCP连接,源码分析
redis.clients.jedis.Jedis#close |
JedisPool源码分析:
Jedis 可以独立使用,也可以配合连接池使用,这个连接池就是 JedisPool。
JedisPool 的 getResource 方法在拿到 Jedis 对象后,将自己设置为了连接池。连接池 JedisPool,继承了 JedisPoolAbstract,而后者继承了抽象类 Pool,Pool 内部维护了 Apache Common 的通用池 GenericObjectPool。JedisPool 的连接池就是基于 GenericObjectPool 的。
public class JedisPool extends JedisPoolAbstract { |
看到这里我们了解了,Jedis 的 API 实现是我们说的三种类型中的第一种,也就是连接池和 连接分离的 API,JedisPool 是线程安全的连接池,Jedis 是非线程安全的单一连接。知道 了原理之后,我们再使用 Jedis 就胸有成竹了。
使用连接池务必确保复用
池一定是用来复用的,否则其使用代价会比每次创建 单一对象更大。对连接池来说更是如此,原因如下
- 创建连接池的时候很可能一次性创建了多个连接,大多数连接池考虑到性能,会在初始 化的时候维护一定数量的最小连接(毕竟初始化连接池的过程一般是一次性的),可以 直接使用。如果每次使用连接池都按需创建连接池,那么很可能你只用到一个连接,但 是创建了 N 个连接。
- 连接池一般会有一些管理模块,也就是连接池的结构示意图中的绿色部分。举个例子, 大多数的连接池都有闲置超时的概念。连接池会检测连接的闲置时间,定期回收闲置的 连接,把活跃连接数降到最低(闲置)连接的配置值,减轻服务端的压力。一般情况 下,闲置连接由独立线程管理,启动了空闲检测的连接池相当于还会启动一个线程。此 外,有些连接池还需要独立线程负责连接保活等功能。因此,启动一个连接池相当于启 动了 N 个线程。
栗子源码
创建一个 CloseableHttpClient,设置使用 PoolingHttpClientConnectionManager 连接池并启用空闲连接驱逐策略,最大空闲时间 为 60 秒,然后使用这个连接来请求一个会返回 OK 字符串的服务端接口:
//错误栗子:每次都创建一个线程池&&只用一个线程 |
jstack、wrk、lsof使用
查看16255线程名包含evictor的线程 |
HttpClient最佳实践&栗子源码
private static CloseableHttpClient httpClient = null; |
这 2 点证明,CloseableHttpClient 属于第二种模式,即内部带有连接池的 API,其背后是 连接池,最佳实践一定是复用。
复用方式很简单,你可以把 CloseableHttpClient 声明为 static,只创建一次,并且在 JVM 关闭之前通过 addShutdownHook 钩子关闭连接池,在使用的时候直接使用 CloseableHttpClient 即可,无需每次都创建。
压测:每次新建立连接池,请求完成后释放连接池 |
如此大的性能差异显然是因为 TCP 连接的复用。你可能注意到了,刚才定义连接池时,我 将最大连接数设置为 1。所以,复用连接池方式复用的始终应该是同一个连接,而新建连接 池方式应该是每次都会创建新的 TCP 连接。
Wireshark分析Http连接池
如果调用 wrong2 接口每次创建新的连接池来发起 HTTP 请求,从 Wireshark 可以看到, 每次请求服务端 45678 的客户端端口都是新的。这里我发起了三次请求,程序通过
使用wireshark分析TCP三次握手,四次挥手,设置窗口大小
- 第一个框:三次握手
- 第二个框:设置窗口大小
- 第三个框:四次挥手
而复用连接池方式的接口 right 的表现就完全不同了。端口复用,说明使用了连接池
连接池的配置不是一成不变的
为方便根据容量规划设置连接处的属性,连接池提供了许多参数,包括最小(闲置)连接、 最大连接、闲置连接生存时间、连接生存时间等。其中,最重要的参数是最大连接数,它决 定了连接池能使用的连接数量上限,达到上限后,新来的请求需要等待其他请求释放连接。
但,最大连接数不是设置得越大越好。如果设置得太大,不仅仅是客户端需要耗费过多的资 源维护连接,更重要的是由于服务端对应的是多个客户端,每一个客户端都保持大量的连接,会给服务端带来更大的压力。这个压力又不仅仅是内存压力,可以想一下如果服务端的 网络模型是一个 TCP 连接一个线程,那么几千个连接意味着几千个线程,如此多的线程会 造成大量的线程切换开销。
当然,连接池最大连接数设置得太小,很可能会因为获取连接的等待时间太长,导致吞吐量 低下,甚至超时无法获取连接。
栗子场景:模拟下压力增大导致数据库连接池打满的情况,来实践下如何确认连接池的 使用情况,以及有针对性地进行参数优化。
栗子源码:
/** |
分析默认hikari连接池最大值和最小值:
当我们使用wrk进行压测的时候,ActiveConnections立刻被打满,大量ThreadsAwaitingConnection。程序出现下面异常
java.sql.SQLTransientConnectionException: HikariPool-1 - Connection is not available, request timed out after 30004ms.
连接获取不到,连接打满了。
解决方案,也很简单调整数据库最大连接池spring.datasource.hikari.maximum-pool-size=50
在这个 Demo 里,我知道压测大概能对应使用 25 左右的并发连接,所以直接把连接池最 大连接设置为了 50。在真实情况下,只要数据库可以承受,你可以选择在遇到连接超限的 时候先设置一个足够大的连接数,然后观察最终应用的并发,再按照实际并发数留出一半的 余量来设置最终的最大连接。
其实,看到错误日志后再调整已经有点儿晚了。更合适的做法是,对类似数据库连接池的重 要资源进行持续检测,并设置一半的使用量作为报警阈值,出现预警后及时扩容。
这里要强调的是,修改配置参数务必验证是否生效,并且在监控系统中确认参数是否生效、 是否合理。之所以要“强调”,是因为这里有坑。
问题:有了连接池之后,获取连接是从连接池获取,没有足够连接时连接池会创建连接。这 时,获取连接操作往往有两个超时时间:
- 一个是从连接池获取连接的最长等待时间,通 常叫作请求超时 connectRequestTimeout 或等待超时 connectWaitTimeout;
- 一个是 连接池新建 TCP 连接三次握手的连接超时,通常叫作连接超时 connectTimeout。针对 JedisPool、Apache HttpClient 和 Hikari 数据库连接池,你知道如何设置这 2 个参数 吗?
|
重点回顾
我以三种业务代码最常用的 Redis 连接池、HTTP 连接池、数据库连接池为例,和 你探讨了有关连接池实现方式、使用姿势和参数配置的三大问题。
- 客户端 SDK 实现连接池的方式,包括池和连接分离、内部带有连接池和非连接池三种。要 正确使用连接池,就必须首先鉴别连接池的实现方式。比如,Jedis 的 API 实现的是池和连 接分离的方式,而 Apache HttpClient 是内置连接池的 API。
- 对于使用姿势其实就是两点,一是确保连接池是复用的,二是尽可能在程序退出之前显式关 闭连接池释放资源。连接池设计的初衷就是为了保持一定量的连接,这样连接可以随取随 用。从连接池获取连接虽然很快,但连接池的初始化会比较慢,需要做一些管理模块的初始 化以及初始最小闲置连接。一旦连接池不是复用的,那么其性能会比随时创建单一连接更 差。
- 最后,连接池参数配置中,最重要的是最大连接数,许多高并发应用往往因为最大连接数不 够导致性能问题。但,最大连接数不是设置得越大越好,够用就好。需要注意的是,针对数 据库连接池、HTTP 连接池、Redis 连接池等重要连接池,务必建立完善的监控和报警机 制,根据容量规划及时调整参数配置。
序列化:一来一回你还是原来的你吗?
今天,我来和你聊聊序列化相关的坑和最佳实践。
序列化是把对象转换为字节流的过程,以方便传输或存储。反序列化,则是反过来把字节流 转换为对象的过程。在介绍文件 IO的时候,我提到字符编码是把字符转换为二进制的过 程,至于怎么转换需要由字符集制定规则。同样地,对象的序列化和反序列化,也需要由序 列化算法制定规则。
关于序列化算法,几年前常用的有 JDK(Java)序列化、XML 序列化等,但前者不能跨语 言,后者性能较差(时间空间开销大);现在 RESTful 应用最常用的是 JSON 序列化,追 求性能的 RPC 框架(比如 gRPC)使用 protobuf 序列化,这 2 种方法都是跨语言的,而 且性能不错,应用广泛。
通常情况下,序列化问题常见的坑会集中在业务场景中,比如 Redis、 参数和响应序列化反序列化。
序列化和反序列化需要确保算法一致
业务代码中涉及序列化时,很重要的一点是要确保序列化和反序列化的算法一致性。
栗子场景:有一次要排查缓存命中率问题,需要运维同学帮忙拉取 Redis 中的 Key,结果他反馈 Redis 中 存的都是乱码,怀疑 Redis 被攻击了。其实呢,这个问题就是序列化算法导致的,我们来 看下吧。
使用 RedisTemplate 来操作 Redis 进行数据缓存。因为相比于 Jedis,使用 Spring 提供的 RedisTemplate 操作 Redis,除了无需考虑连接池、更方便 外,还可以与 Spring Cache 等其他组件无缝整合。如果使用 Spring Boot 的话,无需任 何配置就可以直接使用。
数据(包含 Key 和 Value)要保存到 Redis,需要经过序列化算法来序列化成字符串。虽 然 Redis 支持多种数据结构,比如 Hash,但其每一个 field 的 Value 还是字符串。如果 Value 本身也是字符串的话,能否有便捷的方式来使RedisTemplate,而无需考虑序列 化呢?答案:其实是有的,那就是 StringRedisTemplate。
那 StringRedisTemplate 和 RedisTemplate 的区别是什么呢?开头提到的乱码又是怎么 回事呢?
栗子源码:
|
错误源码分析:
org.springframework.data.redis.core.RedisTemplate#afterPropertiesSet |
redis-cli 看到的类似一串乱码的”\xac\xed\x00\x05t\x00\rredisTemplate”字符串, 其实就是字符串 redisTemplate 经过 JDK 序列化后的结果。
看到这里你可能会说,使用 RedisTemplate 获取 Value 虽然方便,但是 Key 和 Value 不 易读;而使用 StringRedisTemplate 虽然 Key 是普通字符串,但是 Value 存取需要手动 序列化成字符串,有没有两全其美的方式呢?
当然有,自己定义 RedisTemplate 的 Key 和 Value 的序列化方式即可:Key 的序列化使 用 RedisSerializer.string()(也就是 StringRedisSerializer 方式)实现字符串序列化,而 Value 的序列化使用 Jackson2JsonRedisSerializer。
最佳实践源码
|
TODO
分析定位Java问题,一定要用好这些工具(一)
在工作、学习过程中,你会发现我在介绍各种坑的时候,并不是直接给出问题的结论,而是通过工具来亲眼看到问题。
为什么这么做呢?因为我始终认为,遇到问题尽量不要去猜,一定要眼见为实。只有通过日志、监控或工具真正看到问题,然后再回到代码中进行比对确认,我们才能认为是找到了根本原因。
你可能一开始会比较畏惧使用复杂的工具去排查问题,又或者是打开了工具感觉无从下手,但是随着实践越来越多,对 Java 程序和各种框架的运作越来越熟悉,你会发现使用这些工具越来越顺手。其实呢,工具只是我们定位问题的手段,要用好工具主要还是得对程序本身的运作有大概的认识,这需要长期的积累。
今天分享四个栗子:
- 展示使用 JDK 自带的工具来排查 JVM 参数配置问题
- 使用 Wireshark 来分析网络问题
- 通过 MAT 来分析内存问题
- 使用 Arthas 来分析 CPU 使用高的问题
使用 JDK 自带工具查看 JVM 情况
栗子源码:
public static void main(String[] args) throws InterruptedException { |
常用命令
jps |
jstat命令解释
S0 表示 Survivor0 区占用百分比,S1 表示 Survivor1 区占用百分比,E 表示 Eden 区占用百分比,O 表示老年代占用百分比,M 表示元数据区占用百分比,YGC 表示年轻代回收次数,YGCT 表示年轻代回收耗时,FGC 表示老年代回收次数,FGCT 表示老年代回收耗时。
使用 Wireshark 分析 SQL 批量插入慢的问题
有一个数据导入程序需要导入大量的数据,开发同学就想到了使用 Spring JdbcTemplate 的批量操作功能进行数据批量导入,但是发现性能非常差,和普通的单条 SQL 执行性能差不多。
栗子源码:
|
其实,对于批量操作,我们希望程序可以把多条 insert SQL 语句合并成一条,或至少是一次性提交多条语句到数据库,以减少和 MySQL 交互次数、提高性能。那么,我们的程序是这样运作的吗?
使用wireshark抓包工具分析是否真的批量执行了?
源码分析:com.mysql.cj.jdbc.ClientPreparedStatement#executeBatchInternal
|
解决方案:
- 如果有条件的话,优先把 insert 语句优化为一条语句,也就是 executeBatchedInserts 方法;
- 如果不行的话,再尝试把 insert 语句优化为多条语句一起提交,也就是 executePreparedBatchAsMultiStatement 方法。
spring.datasource.url=jdbc:mysql://localhost:6657/common_mistakes?characterEncoding=UTF-8&useSSL=false&rewriteBatchedStatements=true
使用wireshark分析批量操作:
- 这次 insert SQL 语句被拼接成了一条语句
- 这个 TCP 包因为太大被分割成了 11 个片段传输,#852 请求是最后一个片段,其实际内容是 insert 语句的最后一部分内容。
- 查看最开始的握手数据包可以发现,TCP 的最大分段大小(MSS)是 16344 字节,而我们的 MySQL 超长 insert 的数据一共 138933 字节,因此被分成了 11 段传输,其中最大的一段是 16332 字节,低于 MSS 要求的 16344 字节。
问题分析:JDK 中还有一个 jmap 工具,我们会使用 jmap -dump 命令来进行堆转储。那么,这条命令和 jmap -dump:live 有什么区别呢?你能否设计一个实验,来证明下它们的区别呢?
- jmap -dump是会dump所有的对象,不关心是否可达;jmap -dump:live只会dump存活的对象,即可以从GcRoot可达的对象。测试是在循环中,一直创建对象,然后休眠1s,dump2次,发现创建对象的个数不同。
- gc次数不是主要优化目标,gc优化目标一般是吞吐量(throughput) 或者暂停时间(pause times),具体可以搜一下相关资料
分析定位Java问题,一定要用好这些工具(二)
使用MAT分析OOM问题
对于排查 OOM 问题、分析程序堆内存使用情况,最好的方式就是分析堆转储。
堆转储,包含了堆现场全貌和线程栈信息(Java 6 Update 14 开始包含)。我们在上一篇中看到,使用 jstat 等工具虽然可以观察堆内存使用情况的变化,但是对程序内到底有 多少对象、哪些是大对象还一无所知,也就是说只能看到问题但无法定位问题。而堆转储, 就好似得到了病人在某个瞬间的全景核磁影像,可以拿着慢慢分析。
JVM参数一定要加上: -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=.
使用 MAT 分析 OOM 问题,一般可以按照以下思路进行:(直方图、支配树、线程栈、OQL)
- 通过支配树功能或直方图功能查看消耗内存最大的类型,来分析内存泄露的大概原因;
- 查看那些消耗内存最大的类型、详细的对象明细列表,以及它们的引用链,来定位内存 泄露的具体点;
- 配合查看对象属性的功能,可以脱离源码看到对象的各种属性的值和依赖关系,帮助我 们理清程序逻辑和参数;
- 辅助使用查看线程栈来看 OOM 问题是否和过多线程有关,甚至可以在线程栈看到 OOM 最后一刻出现异常的线程。
首先,用 MAT 打开后先进入的是概览信息界面,可以看到整个堆是 437.6MB:
如图所示,工具栏的第二个按钮可以打开直方图,直方图按照类型进行分组,列出了每个类有多少个实例,以及占用的内存。可以看到,char[]字节数组占用内存最多,对象数量也很多,结合第二位的 String 类型对象数量也很多,大概可以猜出(String 使用 char[]作为实际数据存储)程序可能是被字符串占满了内存,导致 OOM。
我们继续分析下,到底是不是这样呢。
在 char[]上点击右键,选择 List objects->with incoming references,就可以列出所有的 char[]实例,以及每个 char[]的整个引用关系链:
接下来,我们按照红色框中的引用链来查看,尝试找到这些大 char[]的来源:
- 在①处看到,这些 char[]几乎都是 10000 个字符、占用 20000 字节左右(char 是 UTF-16,每一个字符占用 2 字节);
- 在②处看到,char[]被 String 的 value 字段引用,说明 char[]来自字符串;
- 在③处看到,String 被 ArrayList 的 elementData 字段引用,说明这些字符串加入了一个 ArrayList 中;
- 在④处看到,ArrayList 又被 FooService 的 data 字段引用,这个 ArrayList 整个 RetainedHeap 列的值是 431MB。
左侧的蓝色框可以查看每一个实例的内部属性,图中显示 FooService 有一个 data 属性,类型是 ArrayList。
Retained Heap(深堆)代表对象本身和对象关联的对象占用的内存,Shallow Heap(浅堆)代表对象本身占用的内存。比如,我们的 FooService 中的 data 这个 ArrayList 对象本身只有 16 字节,但是其所有关联的对象占用了 431MB 内存。这些就可以说明,肯定有哪里在不断向这个 List 中添加 String 数据,导致了 OOM。
如果我们希望看到字符串完整内容的话,可以右键选择 Copy->Value,把值复制到剪贴板或保存到文件中:
其实,我们之前使用直方图定位 FooService,已经走了些弯路。你可以点击工具栏中第三个按钮(下图左上角的红框所示)进入支配树界面(有关支配树的具体概念参考这里)。这个界面会按照对象保留的 Retained Heap 倒序直接列出占用内存最大的对象。
可以看到,第一位就是 FooService,整个路径是 FooSerice->ArrayList->Object[]->String->char[](蓝色框部分),一共有 21523 个字符串(绿色方框部分)。
这样,我们就从内存角度定位到 FooService 是根源了。那么,OOM 的时候,FooService 是在执行什么逻辑呢?
为解决这个问题,我们可以点击工具栏的第五个按钮(下图红色框所示)。打开线程视图,首先看到的就是一个名为 main 的线程(Name 列),展开后果然发现了 FooService:
- 先执行的方法先入栈,所以线程栈最上面是线程当前执行的方法,逐一往下看能看到整个调用路径。因为我们希望了解 FooService.oom() 方法,看看是谁在调用它,它的内部又调用了谁,所以选择以 FooService.oom() 方法(蓝色框)为起点来分析这个调用栈。
- 往下看整个绿色框部分,oom() 方法被 OOMApplication 的 run 方法调用,而这个 run 方法又被 SpringAppliction.callRunner 方法调用。看到参数中的 CommandLineRunner 你应该能想到,OOMApplication 其实是实现了 CommandLineRunner 接口,所以是 SpringBoot 应用程序启动后执行的。
- 以 FooService 为起点往上看,从紫色框中的 Collectors 和 IntPipeline,你大概也可以猜出,这些字符串是由 Stream 操作产生的。再往上看,可以发现在 StringBuilder 的 append 操作的时候,出现了 OutOfMemoryError 异常(黑色框部分),说明这这个线程抛出了 OOM 异常。
我们看到,整个程序是 Spring Boot 应用程序,那么 FooService 是不是 Spring 的 Bean 呢,又是不是单例呢?如果能分析出这点的话,就更能确认是因为反复调用同一个 FooService 的 oom 方法,然后导致其内部的 ArrayList 不断增加数据的。
点击工具栏的第四个按钮(如下图红框所示),来到 OQL 界面。在这个界面,我们可以使用类似 SQL 的语法,在 dump 中搜索数据(你可以直接在 MAT 帮助菜单搜索 OQL Syntax,来查看 OQL 的详细语法)。
比如,输入如下语句搜索 FooService 的实例:SELECT * FROM org.geekbang.time.commonmistakes.troubleshootingtools.oom.FooService
可以看到,一共两处引用:
- 第一处是,OOMApplication 使用了 FooService,这个我们已经知道了。
- 第二处是一个 ConcurrentHashMap。可以看到,这个 HashMap 是 DefaultListableBeanFactory 的 singletonObjects 字段,可以证实 FooService 是 Spring 容器管理的单例的 Bean。
OOM源码栗子
|
使用 Arthas 分析高 CPU 问题
Arthas是阿里开源的 Java 诊断工具,相比 JDK 内置的诊断工具,要更人性化,并且功能强大,可以实现许多问题的一键定位,而且可以一键反编译类查看源码,甚至是直接进行生产代码热修复,实现在一个工具内快速定位和修复问题的一站式服务。今天,我就带你使用 Arthas 定位一个 CPU 使用高的问题,系统学习下这个工具的使用。
help |
栗子源码:
public class HighCPUApplication { |
Arthas命令:dashboard用于整体展示进程所有线程、内存、GC 等情况
可以看到,CPU 高并不是 GC 引起的,占用 CPU 较多的线程有 8 个,其中 7 个是 ForkJoinPool.commonPool。ForkJoinPool.commonPool 是并行流默认使用的线程池。所以,此次 CPU 高的问题,应 该出现在某段并行流的代码上。
接下来,要查看最繁忙的线程在执行的线程栈,可以使用 thread -n 命令。这里,我们查 看下最忙的 8 个线程。
查看最繁忙的n的线程 |
可以看到,由于这些线程都在处理 MD5 的操作,所以占用了大量 CPU 资源。我们希望分 析出代码中哪些逻辑可能会执行这个操作,所以需要从方法栈上找出我们自己写的类,并重 点关注。
由于主线程也参与了 ForkJoinPool 的任务处理,因此我们可以通过主线程的栈看到需要重 点关注。
接下来,使用 jad 命令直接对 HighCPUApplication 类反编译。
jad org.geekbang.time.commonmistakes.troubleshootingtools.arthas.HighCPUApplication |
你可能想到了,通过 jad 命令继续查看 User 类即可。这里因为是 Demo,所以我没有给出 很复杂的逻辑。在业务逻辑很复杂的代码中,判断逻辑不可能这么直白,我们可能还需要分 析出 doTask 的“慢”会慢在什么入参上。
这时,我们可以使用 watch 命令来观察方法入参。如下命令,表示需要监控耗时超过 100 毫秒的 doTask 方法的入参,并且输出入参,展开 2 层入参参数:
watch org.geekbang.time.commonmistakes.troubleshootingtools.arthas.HighCPUApplication doTask '{params}' '#cost>100' -x 2 |
可以看到,所有耗时较久的 doTask 方法的入参都是 0,意味着 User.ADMN_ID 常量应该 是 0。
最后,我们使用 ognl 命令来运行一个表达式,直接查询 User 类的 ADMIN_ID 静态字段 来验证是不是这样,得到的结果果然是 0:
|
需要额外说明的是,由于 monitor、trace、watch 等命令是通过字节码增强技术来实现的,会在指定类的方法中插入一些切面来实现数据统计和观测,因此诊断结束要执行 shutdown 来还原类或方法字节码,然后退出 Arthas。
对于应用故障分析,除了阿里 Arthas 之外,还可以关注去哪儿的Bistoury 工具,其提供 了可视化界面,并且可以针对多台机器进行管理,甚至提供了在线断点调试等功能,模拟 IDE 的调试体验。
问题TODO
- 在介绍线程池的时候,我们模拟了两种可能的 OOM 情况,一种是使用 Executors.newFixedThreadPool,一种是使用 Executors.newCachedThreadPool,你能回忆起 OOM 的原因吗?假设并不知道 OOM 的原因,拿到了这两种 OOM 后的堆转储,你能否尝试使用 MAT 分析堆转储来定位问题呢?
- Arthas 还有一个强大的热修复功能。比如,遇到高 CPU 问题时,我们定位出是管理员用户会执行很多次 MD5,消耗大量 CPU 资源。这时,我们可以直接在服务器上进行热修复,步骤是:jad 命令反编译代码 -> 使用文本编辑器(比如 Vim)直接修改代码 -> 使用 sc 命令查找代码所在类的 ClassLoader-> 使用 redefine 命令热更新代码。你可以尝试使用这个流程,直接修复程序(注释 doTask 方法中的相关代码)吗?
重点回顾
有一次开发同学遇到一个 OOM 问题,通过查监控、查日志、查调用链路排查了数小时也 无法定位问题,但我拿到堆转储文件后,直接打开支配树图一眼就看到了可疑点。Mybatis 每次查询都查询出了几百万条数据,通过查看线程栈马上可以定位到出现 Bug 的方法名, 然后来到代码果然发现因为参数条件为 null 导致了全表查询,整个定位过程不足 5 分钟。
从这个案例我们看到,使用正确的工具、正确的方法来分析问题,几乎可以在几分钟内定位 到问题根因。今天,我和你介绍的 MAT 正是分析 Java 堆内存问题的利器,而 Arthas 是 快速定位分析 Java 程序生产 Bug 的利器。利用好这两个工具,就可以帮助我们在分钟级 定位生产故障。
当反射、注解和泛型遇到OOP时,会有哪些坑?
今天,我们聊聊 Java 高级特性的话题,看看反射、注解和泛型遇到重载 和继承时可能会产生的坑。
你可能说,业务项目中几乎都是增删改查,用到反射、注解和泛型这些高级特性的机会少之 又少,没啥好学的。但我要说的是,只有学好、用好这些高级特性,才能开发出更简洁易读 的代码,而且几乎所有的框架都使用了这三大高级特性。比如,要减少重复代码,就得用到 反射和注解()。
反射调用方法不是以传参决定重载
反射的功能包括,在运行时动态获取类和类成员定义,以及动态读取属性调用方法。也就是 说,针对类动态调用方法,不管类中字段和方法怎么变动,我们都可以用相同的规则来读取 信息和执行方法。因此,几乎所有的 ORM(对象关系映射)、对象映射、MVC 框架都使 用了反射。
反射的起点是 Class 类,Class 类提供了各种方法帮我们查询它的信息。
栗子场景:我们先看一个反射调用方法遇到重载的坑:有两个叫 age 的方法,入参分别是基 本类型 int 和包装类型 Integer。
栗子源码
|
但使用反射时的误区是,认为反射调用方法还是根据入参确定方法重载。现在我们非常清楚了,反射调用方法,是以反射获取方法时传入的方法名称和参数类型来确 定调用方法的。
TODO:泛型经过类型擦除多出桥接方法的坑
泛型是一种风格或范式,一般用于强类型程序设计语言,允许开发者使用类型参数替代明确 的类型,实例化时再指明具体的类型。它是代码重用的有效手段,允许把一套代码应用到多 种数据类型上,避免针对每一种数据类型实现重复的代码。
Java 编译器对泛型应用了强大的类型检测,如果代码违反了类型安全就会报错,可以在编 译时暴露大多数泛型的编码错误。但总有一部分编码错误,比如泛型类型擦除的坑,在运行 时才会暴露。接下来,我就和你分享一个案例吧。
正确姿势,栗子源码
/** |
最后小结下,使用反射查询类方法清单时,我们要注意两点:
- getMethods 和 getDeclaredMethods 是有区别的,前者可以查询到父类方法,后者只能查询到当前类。
- 反射进行方法调用要注意过滤桥接方法。
注解可以继承吗?
注解可以为 Java 代码提供元数据,各种框架也都会利用注解来暴露功能,比如 Spring 框 架中的 @Service、@Controller、@Bean 注解,Spring Boot 的 @SpringBootApplication 注解。
注解可以继承吗?栗子源码
/** |
todo:org.springframework.core.annotation.AnnotatedElementUtils#findMergedAnnotation源码分析
问题思考
泛型类型擦除后会生成一个 bridge 方法,这个方法同时又是 synthetic 方法。除了泛型 类型擦除,你知道还有什么情况编译器会生成 synthetic 方法吗?
关于注解继承问题,你觉得 Spring 的常用注解 @Service、@Controller 是否支持继承 呢?
重点回顾
- 第一,反射调用方法并不是通过调用时的传参确定方法重载,而是在获取方法的时候通过方 法名和参数类型来确定的。遇到方法有包装类型和基本类型重载的时候,你需要特别注意这 一点。
- 第二,反射获取类成员,需要注意 getXXX 和 getDeclaredXXX 方法的区别,其中 XXX 包 括 Methods、Fields、Constructors、Annotations。这两类方法,针对不同的成员类型 XXX 和对象,在实现上都有一些细节差异,详情请查看官方文档。今天提到的 getDeclaredMethods 方法无法获得父类定义的方法,而 getMethods 方法可以,只是差 异之一,不能适用于所有的 XXX。
- 第三,泛型因为类型擦除会导致泛型方法 T 占位符被替换为 Object,子类如果使用具体类 型覆盖父类实现,编译器会生成桥接方法。这样既满足子类方法重写父类方法的定义,又满 足子类实现的方法有具体的类型。使用反射来获取方法清单时,你需要特别注意这一点。
- 第四,自定义注解可以通过标记元注解 @Inherited 实现注解的继承,不过这只适用于类。 如果要继承定义在接口或方法上的注解,可以使用 Spring 的工具类 AnnotatedElementUtils,并注意各种 getXXX 方法和 findXXX 方法的区别,详情查看spring的文档
- 编译后的代码和原始代码并不完全一致,编译器可能会做一些优化,加 上还有诸如 AspectJ 等编译时增强框架,使用反射动态获取类型的元数据可能会和我们编 写的源码有差异,这点需要特别注意。你可以在反射中多写断言,遇到非预期的情况直接抛 异常,避免通过反射实现的业务逻辑不符合预期。
Spring框架:IoC和AOP是扩展的核心
熟悉 Java 的同学都知道,Spring 的家族庞大,常用的模块就有 Spring Data、Spring Security、Spring Boot、Spring Cloud 等。其实呢,Spring 体系虽然庞大,但都是围绕 Spring Core 展开的,而 Spring Core 中最核心的就是 IoC(控制反转)和 AOP(面向切 面编程)。
概括地说,IoC 和 AOP 的初衷是解耦和扩展。理解这两个核心技术,就可以让你的代码变 得更灵活、可随时替换,以及业务组件间更解耦。
首先我们先科普一下IoC和AOP的基础知识:
- IoC,其实就是一种设计思想。使用 Spring 来实现 IoC,意味着将你设计好的对象交给 Spring 容器控制,而不是直接在对象内部控制。那,为什么要让容器来管理对象呢?或许 你能想到的是,使用 IoC 方便、可以实现解耦。但在我看来,相比于这两个原因,更重要 的是 IoC 带来了更多的可能性。
- 如果以容器为依托来管理所有的框架、业务对象,我们不仅可以无侵入地调整对象的关系, 还可以无侵入地随时调整对象的属性,甚至是实现对象的替换。这就使得框架开发者在程序 背后实现一些扩展不再是问题,带来的可能性是无限的。比如我们要监控的对象如果是 Bean,实现就会非常简单。所以,这套容器体系,不仅被 Spring Core 和 Spring Boot 大 量依赖,还实现了一些外部框架和 Spring 的无缝整合。
- AOP,体现了松耦合、高内聚的精髓,在切面集中实现横切关注点(缓存、权限、日志 等),然后通过切点配置把代码注入合适的地方。切面、切点、增强、连接点,是 AOP 中 非常重要的概念,也是我们会大量提及的。
- 切面(Aspect)=切点(Pointcut)+增强(通知Advice)。Spring AOP 中默认使用 AspectJ 查询表达式,通过在连接点运行 查询表达式来匹配切入点。
单例的Bean如何注入Prototype的Bean?
我们虽然知道 Spring 创建的 Bean 默认是单例的,但当 Bean 遇到继承的时候,可能会忽 略这一点。为什么呢?忽略这一点又会造成什么影响呢?接下来,我就和你分享一个由单例 引起内存泄露的案例。
栗子源码:
|
- 在为类标记上 @Service 注解把类型交由容器管理前,首先评估一下类是 否有状态,然后为 Bean 设置合适的 Scope
- Bean 默认是单例的,所以单例的 Controller 注入的 Service 也是一次性创建的,即使 Service 本身标识了 prototype 的范围也没用。
- 修复方式是,让 Service 以代理方式注入。这样虽然 Controller 本身是单例的,但每次都 能从代理获取 Service。这样一来,prototype 范围的配置才能真正生效。
- todo:如何实现先执行SayHello,在执行SayBye方法
监控切面因为顺序问题导致 Spring 事务失效
实现横切关注点,是 AOP 非常常见的一个应用。我曾看到过一个不错的 AOP 实践,通过 AOP 实现了一个整合日志记录、异常处理和方法耗时打点为一体的统一切面。但后来发 现,使用了 AOP 切面后,这个应用的声明式事务处理居然都是无效的。
现在我们来看下这个案例,分析下 AOP 实现的监控组件和事务失效有什么关系,以及通过 AOP 实现监控组件是否还有其他坑。
栗子源码:生产级别的Aop级别日志、方法耗时、入参、返回值,异常处理
/** |
一段时间后,开发同学觉得默认的 @Metrics 配置有点不合适,希望进行两个调整:
- 对于 Controller 的自动打点,不要自动记录入参和出参日志,否则日志量太大;
- MetricsController 手动加上了 @Metrics 注解,设置 logParameters 和 logReturn 为 false(无效)
- 解决方式,我们要知道切入的连接点是方法,注解定义在类上是无法直接从方法上获取到注解 的。修复方式是,改为优先从方法获取,如果获取不到再从类获取,如果还是获取不到再使 用默认的注解。
- 对于 Service 中的方法,最好可以自动捕获异常。
- 然后为 Service 中的 createUser 方法的 @Metrics 注解,设置了 ignoreException 属性为 true(导致异常回滚失效)
- 解决方式是,明确 MetricsAspect 的优先级,可以设置为最高优先级,也就是最先执行入 操作最后执行出操作。
我们分析了 Spring 通过 TransactionAspectSupport 类实现事 务。在 invokeWithinTransaction 方法中设置断点可以发现,在执行 Service 的 createUser 方法时,TransactionAspectSupport 并没有捕获到异常,所以自然无法回滚 事务。原因就是,异常被 MetricsAspect 吃掉了。
我们知道,切面本身是一个 Bean,Spring 对不同切面增强的执行顺序是由 Bean 优先级 决定的,具体规则是:
- 入操作(Around(连接点执行前)、Before),切面优先级越高,越先执行。一个切面 的入操作执行完,才轮到下一切面,所有切面入操作执行完,才开始执行连接点(方 法)。
- 出操作(Around(连接点执行后)、After、AfterReturning、AfterThrowing),切 面优先级越低,越先执行。一个切面的出操作执行完,才轮到下一切面,直到返回到调 用点。
- 同一切面的 Around 比 After、Before 先执行。
对于 Bean 可以通过 @Order 注解来设置优先级,查看 @Order 注解和 Ordered 接口源 码可以发现,默认情况下 Bean 的优先级为最低优先级,其值是 Integer 的最大值。其实, 值越大优先级反而越低,这点比较反直觉:
重点总结
- 第一,让 Spring 容器管理对象,要考虑对象默认的 Scope 单例是否适合,对于有状态的 类型,单例可能产生内存泄露问题。
- 第二,如果要为单例的 Bean 注入 Prototype 的 Bean,绝不是仅仅修改 Scope 属性这么 简单。由于单例的 Bean 在容器启动时就会完成一次性初始化。最简单的解决方案是,把 Prototype 的 Bean 设置为通过代理注入,也就是设置 proxyMode 属性为 TARGET_CLASS。
- 第三,如果一组相同类型的 Bean 是有顺序的,需要明确使用 @Order 注解来设置顺序。 你可以再回顾下,两个不同优先级切面中 @Before、@After 和 @Around 三种增强的执 行顺序,是什么样的。
- 最后我要说的是,文内第二个案例是一个完整的统一日志监控案例,继续修改就可以实现一 个完善的、生产级的方法调用监控平台。这些修改主要是两方面:把日志打点,改为对接 Metrics 监控系统;把各种功能的监控开关,从注解属性获取改为通过配置系统实时获取。
Spring框架:框架帮我们做了很多工作也带来了复杂度
Spring 框架内部的复杂度主要表现为三点:
- 第一,Spring 框架借助 IoC 和 AOP 的功能,实现了修改、拦截 Bean 的定义和实例的 灵活性,因此真正执行的代码流程并不是串行的。
- 第二,Spring Boot 根据当前依赖情况实现了自动配置,虽然省去了手动配置的麻烦, 但也因此多了一些黑盒、提升了复杂度。
- 第三,Spring Cloud 模块多版本也多,Spring Boot 1.x 和 2.x 的区别也很大。如果要 对 Spring Cloud 或 Spring Boot 进行二次开发的话,考虑兼容性的成本会很高。
Feign AOP 切不到的诡异案例
- 曾遇到过这么一个案例:使用 Spring Cloud 做微服务调用,为方便统一处理 Feign,想到了用 AOP 实现,即使用 within 指示器匹配 feign.Client 接口的实现进行 AOP 切入。
- 栗子源码
|
一开始项目使用Ribbon来负载均衡,代码没什么问题,后来因为后端服务通过 Nginx 实现服务端负载均衡,所以开发同学把 @FeignClient 的配置设置了 URL 属性,直接通过一个固定 URL 调用后端服务。
问题:导致原先定义的切面失效
源码分析Feign创建过程:org.springframework.cloud.openfeign.FeignClientFactoryBean
//FactoryBean就是创建bean的工程,直接看getObject()方法 |
- 当 URL 没有内容也就是为空或者不配置时调用 loadBalance 方法,在其内部通过 FeignContext 从容 器获取 feign.Client 的实例。是spring容器管理的bean。
- FactoryBean就是创建bean的工程,直接看getObject()方法
- 当 URL 不为空的时候,client 设置为了 LoadBalanceFeignClient 的 delegate 属性。其原因注释中有提到,因为有了 URL 就不需 要客户端负载均衡了,但因为 Ribbon 在 classpath 中,所以需要从 LoadBalanceFeignClient 提取出真正的 Client。断点调试下可以看到,这时 client 是一个 ApacheHttpClient。
- 表达式声明的是切入 feign.Client 的实现类(Spring 只能切入由自己管理的 Bean)
- 虽然 LoadBalancerFeignClient 和 ApacheHttpClient 都是 feign.Client 接口的实 现,但是 HttpClientFeignLoadBalancedConfiguration 的自动配置只是把前者定义 为 Bean,后者是 new 出来的、作为了 LoadBalancerFeignClient 的 delegate,不 是 Bean。
- 在定义了 FeignClient 的 URL 属性后,我们获取的是 LoadBalancerFeignClient 的 delegate,它不是 Bean。
改进方案1:更换为切面表达式
/** |
这次切入的是 ClientWithUrl 接口的 API 方法,并不是 client.Feign 接口的 execute 方法,显然不符合预期。
- 没有弄清楚真正希望切的是什么对象。@FeignClient 注解标记在 Feign Client 接口上,所以切的是 Feign 定义的接口,也就是每一个实际的 API 接口。而 通过 feign.Client 接口切的是客户端实现类,切到的是通用的、执行所有 Feign 调用的 execute 方法。
那么问题来了,ApacheHttpClient 不是 Bean 无法切入,切 Feign 接口本身又不符合要 求。怎么办呢?
经过一番研究发现,ApacheHttpClient 其实有机会独立成为 Bean。查看 HttpClientFeignConfiguration 的源码可以发现,当没有 ILoadBalancer 类型的时候,自 动装配会把 ApacheHttpClient 设置为 Bean。
源码分析:
protected static class HttpClientFeignConfiguration {
public Client feignClient(HttpClient httpClient) {
return new ApacheHttpClient(httpClient);
}
}移除ribbon的pom依赖之后,
Could not generate CGLIB subclass of class feign.httpclient.ApacheHttpClient: Common causes of this problem include using a final class or a non-visible class; nested exception is java.lang.IllegalArgumentException: Cannot subclass final class feign.httpclient.ApacheHttpClient
spring实现动态代理的两种方式
- JDK 动态代理,通过反射实现,只支持对实现接口的类进行代理;
- CGLIB 动态字节码注入方式,通过继承实现代理,没有这个限制。
- Spring Boot 2.x 默认使用 CGLIB 的方式,但通过继承实现代理有个问题是,无法继承 final 的类。因为,ApacheHttpClient 类就是定义为了 final。
- 解决方案:
spring.aop.proxy-target-class=false
,优先使用jdk代理,再使用CGLIB
Spring程序配置的优先级问题
我们来通过一个实际案例,研究下配置源以及配置源的优先级问题。
要想查询 Spring 中所有的配置,我们需要以环境 Environment 接口为入口。接下来,我 就与你说说 Spring 通过环境 Environment 抽象出的 Property 和 Profile
- 针对 Property,又抽象出各种 PropertySource 类代表配置源。一个环境下可能有多个 配置源,每个配置源中有诸多配置项。在查询配置信息时,需要按照配置源优先级进行 查询
- Profile 定义了场景的概念。通常,我们会定义类似 dev、test、stage 和 prod 等环境 作为不同的 Profile,用于按照场景对 Bean 进行逻辑归属。同时,Profile 和配置文件也 有关系,每个环境都有独立的配置文件,但我们只会激活某一个环境来生效特定环境的 配置文件
图解说明Spring配置
源码分析spring配置优先级:
配置文件优先级和顺序:
- ConfigurationPropertySourcesPropertySource {name=’configurationProperties’}
- StubPropertySource {name=’servletConfigInitParams’}
- ServletContextPropertySource {name=’servletContextInitParams’}
- PropertiesPropertySource {name=’systemProperties’}(JVM系统配置)
- OriginAwareSystemEnvironmentPropertySource {name=’systemEnvironment’}(环境变量配置)
- RandomValuePropertySource {name=’random’}
- OriginTrackedMapPropertySource {name=’applicationConfig: [classpath:/application.properties]’}(配置文件配置)
- MapPropertySource {name=’springCloudClientHostInfo’}
- MapPropertySource {name=’defaultProperties’}
源码分析为什么
PropertySourcesPropertyResolver
的优先级最高?/**
* 紫色框:StandardEnvironment,继承的是 AbstractEnvironment
**/
public abstract class AbstractEnvironment implements ConfigurableEnvironment {
/**
*MutablePropertySources 类型的字段 propertySources,看起来代表了所有配置源; getProperty 方法,通过 *PropertySourcesPropertyResolver 类进行查询配置;
*实例化 PropertySourcesPropertyResolver 的时候,传入了当前的 MutablePropertySources。
**/
private final MutablePropertySources propertySources = new MutablePropertySources();
private final ConfigurablePropertyResolver propertyResolver =
new PropertySourcesPropertyResolver(this.propertySources);
public String getProperty(String key) {
return this.propertyResolver.getProperty(key);
}
}
/**
*蓝色框:MutablePropertySources 和 PropertySourcesPropertyResolver
*propertySourceList 字段用来真正保存 PropertySource 的 List,且这个 List 是一个 CopyOnWriteArrayList。
*
*类中定义了 addFirst、addLast、addBefore、addAfter 等方法,来精确控制 PropertySource 加入*propertySourceList 的顺序。这也说明了顺序的重要性。
*
*
**/
public class MutablePropertySources implements PropertySources {
private final List<PropertySource<?>> propertySourceList = new CopyOnWriteArrayList<>();
/**
* Add the given property source object with highest precedence.
*/
public void addFirst(PropertySource<?> propertySource) {
removeIfPresent(propertySource);
this.propertySourceList.add(0, propertySource);
}
/**
* Add the given property source object with lowest precedence.
*/
public void addLast(PropertySource<?> propertySource) {
removeIfPresent(propertySource);
this.propertySourceList.add(propertySource);
}
/**
* Add the given property source object with precedence immediately higher
* than the named relative property source.
*/
public void addBefore(String relativePropertySourceName, PropertySource<?> propertySource) {
assertLegalRelativeAddition(relativePropertySourceName, propertySource);
removeIfPresent(propertySource);
int index = assertPresentAndGetIndex(relativePropertySourceName);
addAtIndex(index, propertySource);
}
/**
* Add the given property source object with precedence immediately lower
* than the named relative property source.
*/
public void addAfter(String relativePropertySourceName, PropertySource<?> propertySource) {
assertLegalRelativeAddition(relativePropertySourceName, propertySource);
removeIfPresent(propertySource);
int index = assertPresentAndGetIndex(relativePropertySourceName);
addAtIndex(index + 1, propertySource);
}
}
/**
* 绿色框:
* 遍历的 propertySources 是 PropertySourcesPropertyResolver 构造方法传入的,再结合 AbstractEnvironment ** 的源 码可以发现,这个 propertySources 正是 AbstractEnvironment 中的 MutablePropertySources 对象。遍历** 时,如果发现配置源中有对应的 Key 值,则使用这 个值。因此,MutablePropertySources 中配置源的次序尤为重要。
**/
public class PropertySourcesPropertyResolver extends AbstractPropertyResolver {
private final PropertySources propertySources;
protected <T> T getProperty(String key, Class<T> targetValueType, boolean resolveNestedPlaceholders) {
if (this.propertySources != null) {
for (PropertySource<?> propertySource : this.propertySources) {
if (logger.isTraceEnabled()) {
logger.trace("Searching for key '" + key + "' in PropertySource '" +
propertySource.getName() + "'");
}
Object value = propertySource.getProperty(key);
if (value != null) {
if (resolveNestedPlaceholders && value instanceof String) {
value = resolveNestedPlaceholders((String) value);
}
logKeyFound(key, propertySource, value);
return convertValueIfNecessary(value, targetValueType);
}
}
}
if (logger.isTraceEnabled()) {
logger.trace("Could not find key '" + key + "' in any property source");
}
return null;
}
}
/**
* 红色框:回到之前的问题,在查询所有配置源的时候,我们注意到处在第一位的是**ConfigurationPropertySourcesPropertySource,这是什么呢?
**其实,它不是一个实际存在的配置源,扮演的是一个代理的角色。但通过调试你会发现,我 们获取的值竟然是由它提供并且返**回的,且没有循环遍历后面的 PropertySource。
**getProperty 方法其实是通过 findConfigurationProperty 方法查询配置的
**/
class ConfigurationPropertySourcesPropertySource extends PropertySource<Iterable<ConfigurationPropertySource>>
implements OriginLookup<String> {
ConfigurationPropertySourcesPropertySource(String name, Iterable<ConfigurationPropertySource> source) {
super(name, source);
}
public Object getProperty(String name) {
ConfigurationProperty configurationProperty = findConfigurationProperty(name);
return (configurationProperty != null) ? configurationProperty.getValue() : null;
}
public Origin getOrigin(String name) {
return Origin.from(findConfigurationProperty(name));
}
private ConfigurationProperty findConfigurationProperty(String name) {
try {
return findConfigurationProperty(ConfigurationPropertyName.of(name, true));
}
catch (Exception ex) {
return null;
}
}
private ConfigurationProperty findConfigurationProperty(ConfigurationPropertyName name) {
if (name == null) {
return null;
}
//调试可以发现,这个循环遍历(getSource() 的结果)的配置源,其实是 SpringConfigurationPropertySources(图中黄色类),其中包含的配置源列表就是之前 看到的 9 个配置源,而第一个就是 ConfigurationPropertySourcesPropertySource。看 到这里,我们的第一感觉是会不会产生死循环,它在遍历的时候怎么排除自己呢?
for (ConfigurationPropertySource configurationPropertySource : getSource()) {
ConfigurationProperty configurationProperty = configurationPropertySource.getConfigurationProperty(name);
if (configurationProperty != null) {
return configurationProperty;
}
}
return null;
}
}
/**
**黄色框:
**看到这里,我们的第一感觉是会不会产生死循环,它在遍历的时候怎么排除自己呢?
**,它返回的迭代器是内部类 SourcesIterator,在 fetchNext 方法获取下一个项时,通过 isIgnored 方法排除了**ConfigurationPropertySourcesPropertySource
**/
class SpringConfigurationPropertySources implements Iterable<ConfigurationPropertySource> {
private final Iterable<PropertySource<?>> sources;
private final Map<PropertySource<?>, ConfigurationPropertySource> cache = new ConcurrentReferenceHashMap<>(16,
ReferenceType.SOFT);
SpringConfigurationPropertySources(Iterable<PropertySource<?>> sources) {
Assert.notNull(sources, "Sources must not be null");
this.sources = sources;
}
public Iterator<ConfigurationPropertySource> iterator() {
return new SourcesIterator(this.sources.iterator(), this::adapt);
}
private ConfigurationPropertySource adapt(PropertySource<?> source) {
ConfigurationPropertySource result = this.cache.get(source);
// Most PropertySources test equality only using the source name, so we need to
// check the actual source hasn't also changed.
if (result != null && result.getUnderlyingSource() == source) {
return result;
}
result = SpringConfigurationPropertySource.from(source);
this.cache.put(source, result);
return result;
}
private static class SourcesIterator implements Iterator<ConfigurationPropertySource> {
private final Deque<Iterator<PropertySource<?>>> iterators;
private ConfigurationPropertySource next;
private final Function<PropertySource<?>, ConfigurationPropertySource> adapter;
SourcesIterator(Iterator<PropertySource<?>> iterator,
Function<PropertySource<?>, ConfigurationPropertySource> adapter) {
this.iterators = new ArrayDeque<>(4);
this.iterators.push(iterator);
this.adapter = adapter;
}
public boolean hasNext() {
return fetchNext() != null;
}
public ConfigurationPropertySource next() {
ConfigurationPropertySource next = fetchNext();
if (next == null) {
throw new NoSuchElementException();
}
this.next = null;
return next;
}
private ConfigurationPropertySource fetchNext() {
if (this.next == null) {
if (this.iterators.isEmpty()) {
return null;
}
if (!this.iterators.peek().hasNext()) {
this.iterators.pop();
return fetchNext();
}
PropertySource<?> candidate = this.iterators.peek().next();
if (candidate.getSource() instanceof ConfigurableEnvironment) {
push((ConfigurableEnvironment) candidate.getSource());
return fetchNext();
}
//细节
if (isIgnored(candidate)) {
return fetchNext();
}
this.next = this.adapter.apply(candidate);
}
return this.next;
}
private void push(ConfigurableEnvironment environment) {
this.iterators.push(environment.getPropertySources().iterator());
}
private boolean isIgnored(PropertySource<?> candidate) {
return (candidate instanceof StubPropertySource
|| candidate instanceof ConfigurationPropertySourcesPropertySource);
}
}
}
/**
**最后一个问题是,ConfigurationPropertySourcesPropertySource它如何让自己成为第一个配置源呢?
**/
public final class ConfigurationPropertySources {
public static void attach(Environment environment) {
Assert.isInstanceOf(ConfigurableEnvironment.class, environment);
MutablePropertySources sources = ((ConfigurableEnvironment) environment).getPropertySources();
PropertySource<?> attached = sources.get(ATTACHED_PROPERTY_SOURCE_NAME);
if (attached != null && attached.getSource() != sources) {
sources.remove(ATTACHED_PROPERTY_SOURCE_NAME);
attached = null;
}
if (attached == null) {
sources.addFirst(new ConfigurationPropertySourcesPropertySource(ATTACHED_PROPERTY_SOURCE_NAME,
new SpringConfigurationPropertySources(sources)));
}
}
}
public class SpringApplication {
private ConfigurableEnvironment prepareEnvironment(SpringApplicationRunListeners listeners,
ApplicationArguments applicationArguments) {
// Create and configure the environment
ConfigurableEnvironment environment = getOrCreateEnvironment();
configureEnvironment(environment, applicationArguments.getSourceArgs());
//调用attach方法
ConfigurationPropertySources.attach(environment);
listeners.environmentPrepared(environment);
bindToSpringApplication(environment);
if (!this.isCustomEnvironment) {
environment = new EnvironmentConverter(getClassLoader()).convertEnvironmentIfNecessary(environment,
deduceEnvironmentClass());
}
ConfigurationPropertySources.attach(environment);
return environment;
}
//SpringApplication#run()
public ConfigurableApplicationContext run(String... args) {
StopWatch stopWatch = new StopWatch();
stopWatch.start();
ConfigurableApplicationContext context = null;
Collection<SpringBootExceptionReporter> exceptionReporters = new ArrayList<>();
configureHeadlessProperty();
SpringApplicationRunListeners listeners = getRunListeners(args);
listeners.starting();
try {
ApplicationArguments applicationArguments = new DefaultApplicationArguments(args);
//调用prepareEnviroment方法
ConfigurableEnvironment environment = prepareEnvironment(listeners, applicationArguments);
configureIgnoreBeanInfo(environment);
Banner printedBanner = printBanner(environment);
context = createApplicationContext();
exceptionReporters = getSpringFactoriesInstances(SpringBootExceptionReporter.class,
new Class[] { ConfigurableApplicationContext.class }, context);
prepareContext(context, environment, listeners, applicationArguments, printedBanner);
refreshContext(context);
afterRefresh(context, applicationArguments);
stopWatch.stop();
if (this.logStartupInfo) {
new StartupInfoLogger(this.mainApplicationClass).logStarted(getApplicationLog(), stopWatch);
}
listeners.started(context);
callRunners(context, applicationArguments);
}
catch (Throwable ex) {
handleRunFailure(context, ex, exceptionReporters, listeners);
throw new IllegalStateException(ex);
}
try {
listeners.running(context);
}
catch (Throwable ex) {
handleRunFailure(context, ex, exceptionReporters, null);
throw new IllegalStateException(ex);
}
return context;
}
}
思考和讨论
Spring 的 Environment 中的 PropertySources 属性可以包含多个 PropertySource, 越往前优先级越高。那,我们能否利用这个特点实现配置文件中属性值的自动赋值呢? 比如,我们可以定义 %%MYSQL.URL%%、%%MYSQL.USERNAME%% 和 %%MYSQL.PASSWORD%%,分别代表数据库连接字符串、用户名和密码。在配置数 据源时,我们只要设置其值为占位符,框架就可以自动根据当前应用程序名 application.name,统一把占位符替换为真实的数据库信息。这样,生产的数据库信息 就不需要放在配置文件中了,会更安全。(换句话:如何实现配置数据库等bootstart的配置不写死,这样也解决了bootstart每次都重启的问题)
/** |
Noah-Java最佳实践与踩坑
本篇文章是学习极客时间,朱晔老师Java业务开发常见错误100例。如需要转载请联系博主本人和朱晔老师。