Java 业务开发常见错误 100 例(一)
第一讲:使用并发工具库类,建议
容易犯的四类错:
-
只知道使用并发工具,但并不清楚当前线程的来龙去脉,解决多线程问题却不了解线程;--错误
-
误以为使用了并发工具就可以解决一切线程安全问题,期望通过把线程不安全的类替换为线程安全的类来一键解决问题。--错误
-
没有充分了解并发工具的特性,还是按照老方式使用新工具导致无法发挥其性能。
-
没有了解清楚工具的适用场景,在不合适的场景下使用了错误的工具导致性能更差。
两点建议:
-
一定要认真阅读官方文档(比如 Oracle JDK 文档)。充分阅读官方文档,理解工具的适用场景及其 API 的用法,并做一些小实验。了解之后再去使用,就可以避免大部分坑。
-
如果你的代码运行在多线程环境下,那么就会有并发问题,并发问题不那么容易重现,可能需要使用压力测试模拟并发场景,来发现其中的 Bug 或性能问题。
第二讲:代码加锁
加锁范围
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 | import lombok.Getter; import java.util.stream.IntStream; public class Test { public static void main(String[] args) throws Exception { IntStream.rangeClosed(1, 100000).parallel().forEach(i -> new Data().wrong()); System. out .println(Data.getCounter()); } static class Data { @Getter private static int counter = 0; public Data() { } public synchronized void wrong() { counter++; } } } |
以上代码的输出结果为:36529;为什么不是100000。
问题分析:在非静态的 wrong 方法上加锁,只能确保多个线程无法执行同一个实例的 wrong 方法,却不能保证不会执行不同实例的 wrong 方法。而静态的 counter 在多个实例中共享,所以必然会出现线程安全问题。
修改后:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 | import lombok.Getter; import java.util.stream.IntStream; public class Test { public static void main(String[] args) throws Exception { IntStream.rangeClosed(1, 100000).parallel().forEach(i -> new Data().right1()); System. out .println(Data.getCounter()); } static class Data { @Getter private static int counter = 0; private static Object locker = new Object(); // 添加一个静态类锁 public Data() { } public void right1() { synchronized (locker) { counter++; } } public static synchronized void right2(){ counter++; } } } |
以上有两种方法可以解决这个问题,一个是代码块级别的synchronized ,另一个是方法上标记 synchronized 关键字;
这两种解决方式有什么差异:
1- 代码块级别:更灵活一些,锁的范围也更小一些; 最主要的差异,非静态的同步方法是实例级别的锁;
2- static方法级别:灵活性差些。是类级别的锁;
作者给的答案是第一种,第二种属于滥用synchronized的做法,原因如下:
1- 对于无状态的方法,没有必要;
2- 可能会极大地降低性能。
即使我们确实有一些共享资源需要保护,也要尽可能降低锁的粒度,仅对必要的代码块甚至是需要保护的资源本身加锁。
如果精细化考虑了锁应用范围后,性能还无法满足需求的话,我们就要考虑另一个维度的粒度问题了,即:区分读写场景以及资源的访问冲突,考虑使用悲观方式的锁还是乐观方式的锁。
多把锁要小心死锁问题
日志:Found one Java-level deadlock
死锁:线程4等待线程3,线程3等待线程4;
错误代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 | @GetMapping( "wrong" ) public long wrong() { long begin = System.currentTimeMillis(); //并发进行100次下单操作,统计成功次数 long success = IntStream.rangeClosed(1, 100).parallel() .mapToObj(i -> { List<Item> cart = createCart(); return createOrder(cart); }) .filter(result -> result) .count(); log.info( "success:{} totalRemaining:{} took:{}ms items:{}" , success, items.entrySet().stream().map(item -> item.getValue().remaining).re System.currentTimeMillis() - begin, items); return success; } |
正确代码(排序):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 | @GetMapping( "right" ) public long right() { ... long success = IntStream.rangeClosed(1, 100).parallel() .mapToObj(i -> { List<Item> cart = createCart().stream() .sorted(Comparator.comparing(Item::getName)) .collect(Collectors.toList()); return createOrder(cart); }) .filter(result -> result) .count(); ... return success; } |
如果业务逻辑中锁的实现比较复杂的话,要仔细看看加锁和释放是否配对,是否有遗漏释放或重复释放的可能性;并且要考虑锁自动超时释放了,而业务逻辑却还在进行的情况下,如果别的线线程或进程拿到了相同的锁,可能会导致重复执行。
我理解的是: 1- 锁机制排序加锁; 2- 超时释放锁; 这两种方式是目前解决死锁的两个基本方法。
三: 线程池
问题:
为什么阿里巴巴 禁止使用这些方法来创建线程,最典型的就是 newFixedThreadPool 和 newCachedThreadPool。
答案:可能会资源耗尽导致OOM问题。
先看newFixedThreadPool:
public static void main(String[] args) throws InterruptedException {
final ThreadPoolExecutor threadPool = (ThreadPoolExecutor) Executors.newFixedThreadPool(1);
for (int i = 0; i < 1000000000; i++) {
threadPool.execute(() -> {
String payload = IntStream.rangeClosed(1, 1000000)
.mapToObj(__ -> "a")
.collect(Collectors.joining("")) + UUID.randomUUID().toString();
try {
TimeUnit.HOURS.sleep(1);
} catch (InterruptedException e) {
}
});
System.out.println("-------------" + i);
log.info("=========================");
log.info("Pool Size: {}", threadPool.getPoolSize());
log.info("Active Threads: {}", threadPool.getActiveCount());
log.info("Number of Tasks Completed: {}", threadPool.getCompletedTaskCount());
log.info("Number of Tasks in Queue: {}", threadPool.getQueue().size());
log.info("=========================");
}
threadPool.shutdown();
threadPool.awaitTermination(1, TimeUnit.HOURS);
}
源码:
public static ExecutorService newFixedThreadPool(int nThreads) {
return new ThreadPoolExecutor(nThreads, nThreads,
0L, TimeUnit.MILLISECONDS,
new LinkedBlockingQueue<Runnable>());
}
原因分析:
不能处理的任务会放在LinkedBlockingQueue中,而这个size最大值为Integer.MAX_VALUE,相当于无线。所以会存在OOM的风险;
再看newCachedThreadPool:
1 2 | 这种线程池的最大线程数是 Integer.MAX_VALUE,可以认为是没有上限的,而其工作队列 SynchronousQueue 是一个没有存储空间的阻塞队列。 这意味着,只要有请求到来,就必须找到一条工作线程来处理,如果当前没有空闲的线程就再创建一条新的。 |
-
建议:
1- 根据自己的场景、并发情况来评估线程池的几个核心参数,包括核心线程数、最大线程数、线程回收策略、工作队列的类型,以及拒绝策略,确保线程池的工作行为符合需求,一般都需要设置有界的工作队列和可控的线程数。
2- 应该为自定义线程池指定有意义的名称,以方便排查问题。当出现线程数量暴增、线程死锁、线程占用大量 CPU、线程执行出现异常等问题时,我们往往会抓取线程栈。此时,有意义的线程名称,就可以方便我们定位问题。
3- 建议用一些监控手段来观察线程池的状态。提前观察到线程池队列的积压,或者线程数量的快速膨胀,往往可以提早发现并解决问题。
四:连接池
问:怎么鉴别客户端sdk是否基于连接池
从命名习惯:
- 连接池和连接分离的 API:有一个 XXXPool 类负责连接池实现,先从其获得连接XXXConnection,然后用获得的连接进行服务端请求,完成后使用者需要归还连接。通常,XXXPool 是线程安全的,可以并发获取和归还连接,而 XXXConnection 是非线程安全的。对应到连接池的结构示意图中,XXXPool 就是右边连接池那个框,左边的客户端是我们自己的代码。
- 内部带有连接池的 API:对外提供一个 XXXClient 类,通过这个类可以直接进行服务端请求;这个类内部维护了连接池,SDK 使用者无需考虑连接的获取和归还问题。一般而言,XXXClient 是线程安全的。对应到连接池的结构示意图中,整个 API 就是蓝色框包裹的部分。
- 非连接池的 API:一般命名为 XXXConnection,以区分其是基于连接池还是单连接的,而不建议命名为 XXXClient 或直接是 XXX。直接连接方式的 API 基于单一连接,每次使用都需要创建和断开连接,性能一般,且通常不是线程安全的。对应到连接池的结构示意图中,这种形式相当于没有右边连接池那个框,客户端直接连接服务端创建连接。
问:怎么使用连接池
- 如果是分离方式,那么连接池本身一般是线程安全的,可以复用。每次使用需要从连接池获取连接,使用后归还,归还的工作由使用者负责。
- 如果是内置连接池,SDK 会负责连接的获取和归还,使用的时候直接复用客户端。
- 如果 SDK 没有实现连接池(大多数中间件、数据库的客户端 SDK 都会支持连接池),那通常不是线程安全的,而且短连接的方式性能不会很高,使用的时候需要考虑是否自己封装一个连接池。
举例子(看redis源码):
直接上结论:
Jedis jedis = new Jedis("127.0.0.1", 6379) 对应上面的第三种情况,属于非连接池的API;
JedisPool jedisPool = new JedisPool("127.0.0.1", 6379); 对应上面的第二种情况,属于内置连接池;
使用连接池务必确保复用:
错误demo:每次都创建一个可复用的连接池;
1 2 3 4 5 6 7 8 9 10 11 12 | @GetMapping( "wrong1" ) public String wrong1() { CloseableHttpClient client = HttpClients.custom() .setConnectionManager( new PoolingHttpClientConnectionManager()) .evictIdleConnections(60, TimeUnit.SECONDS).build(); try (CloseableHttpResponse response = client.execute( new HttpGet("http: //12 return EntityUtils.toString(response.getEntity()); } catch (Exception ex) { ex.printStackTrace(); } return null ; }<br><br> |
@GetMapping("wrong2")
public String wrong2() {
try (CloseableHttpClient client = HttpClients.custom()
.setConnectionManager(new PoolingHttpClientConnectionManager())
.evictIdleConnections(60, TimeUnit.SECONDS).build();
CloseableHttpResponse response = client.execute(new HttpGet("http://12
return EntityUtils.toString(response.getEntity());
} catch (Exception ex) {
ex.printStackTrace();
}
return null;
}
解决办法CloseableHttpClient不要重复创建,可以用static进行复用。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 | private static CloseableHttpClient httpClient = null ; static { //当然,也可以把CloseableHttpClient定义为Bean,然后在@PreDestroy标记的方法内close httpClient = HttpClients.custom().setMaxConnPerRoute(1).setMaxConnTotal(1) Runtime.getRuntime().addShutdownHook( new Thread(() -> { try { httpClient.close(); } catch (IOException ignored) { } })); } @GetMapping( "right" ) public String right() { try (CloseableHttpResponse response = httpClient.execute( new HttpGet("http return EntityUtils.toString(response.getEntity()); } catch (Exception ex) { ex.printStackTrace(); } return null ; } |
连接池的配置不是一成不变的:
主旨:
最大连接数不是设置得越大越好:浪费资源+线程切换开销
连接池最大连接数设置得太小,很可能会因为获取连接的等待时间太长,导致吞吐量低下,甚至超时无法获取连接。
监控方法:
spring.datasource.hikari.register-mbeans=true + JConsole工具
使用宗旨:
观察一下这个参数是否适合当前压力,满足需求的同时也不占用过多资源。从监控来看这个调整是合理的,有一半的富余资源,再也没有线程需要等待连接了。
调整参数:spring.datasource.hikari.maximum-pool-size=50
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· winform 绘制太阳,地球,月球 运作规律
· 超详细:普通电脑也行Windows部署deepseek R1训练数据并当服务器共享给他人
· 上周热点回顾(3.3-3.9)
· AI 智能体引爆开源社区「GitHub 热点速览」
· 写一个简单的SQL生成工具
2018-10-30 hadoop hdfs问题集锦