gitbook/Java业务开发常见错误100例/docs/260695.md
2022-09-03 22:05:03 +08:00

23 KiB
Raw Blame History

答疑篇:代码篇思考题集锦(一)

你好,我是朱晔。

在回复《Java 业务开发常见错误100例》这门课留言的过程中我看到有些同学特别想看一看咱们这个课程所有思考题的答案。因此呢我特地将这个课程涉及的思考题进行了梳理把其中的67个问题的答案或者说解题思路详细地写了出来并整理成了一个“答疑篇”模块。

我把这些问题拆分为了6篇分别更新你可以根据自己的时间来学习以保证学习效果。你可以通过这些回答再来回顾下这些知识点以求温故而知新同时你也可以对照着我的回答对比下自己的解题思路看看有没有什么不一样的地方并留言给我。

今天是答疑篇的第一讲我们一起来分析下咱们这门课前6讲的课后思考题。这些题目涉及了并发工具、代码加锁、线程池、连接池、HTTP调用和Spring声明式事务的12道思考题。

接下来,我们就一一具体分析吧。

01 | 使用了并发工具类库,线程安全就高枕无忧了吗?

**问题1**ThreadLocalRandom是Java 7引入的一个生成随机数的类。你觉得可以把它的实例设置到静态变量中在多线程情况下重用吗

答:不能。

ThreadLocalRandom文档里有这么一条:

Usages of this class should typically be of the form: ThreadLocalRandom.current().nextX(…) (where X is Int, Long, etc). When all usages are of this form, it is never possible to accidently share a ThreadLocalRandom across multiple threads.

那为什么规定要ThreadLocalRandom.current().nextX(…)这样来使用呢?我来分析下原因吧。

current()的时候初始化一个初始化种子到线程每次nextseed再使用之前的种子生成新的种子

UNSAFE.putLong(t = Thread.currentThread(), SEED, r = UNSAFE.getLong(t, SEED) + GAMMA);

如果你通过主线程调用一次current生成一个ThreadLocalRandom的实例保存起来那么其它线程来获取种子的时候必然取不到初始种子必须是每一个线程自己用的时候初始化一个种子到线程。你可以在nextSeed方法设置一个断点来测试

UNSAFE.getLong(Thread.currentThread(),SEED);

**问题2**ConcurrentHashMap还提供了putIfAbsent方法你能否通过查阅JDK文档说说computeIfAbsent和putIfAbsent方法的区别

computeIfAbsent和putIfAbsent这两个方法都是判断值不存在的时候为Map进行赋值的原子方法它们的区别具体包括以下三个方面

  1. 当Key存在的时候如果Value的获取比较昂贵的话putIfAbsent方法就会白白浪费时间在获取这个昂贵的Value上这个点特别注意而computeIfAbsent则会因为传入的是Lambda表达式而不是实际值不会有这个问题。
  2. Key不存在的时候putIfAbsent会返回null这时候我们要小心空指针而computeIfAbsent会返回计算后的值不存在空指针的问题。
  3. 当Key不存在的时候putIfAbsent允许put null进去而computeIfAbsent不能当然了此条针对HashMapConcurrentHashMap不允许put null value进去

我写了一段代码来证明这三点,你可以点击这里的GitHub链接查看。

02 | 代码加锁:不要让“锁”事成为烦心事

**问题1**在这一讲开头的例子里我们为变量a、b都使用了volatile关键字进行修饰你知道volatile关键字的作用吗我之前遇到过这样一个坑我们开启了一个线程无限循环来跑一些任务有一个bool类型的变量来控制循环的退出默认为true代表执行一段时间后主线程将这个变量设置为了false。如果这个变量不是volatile修饰的子线程可以退出吗你能否解释其中的原因呢

不能退出。比如下面的代码3秒后另一个线程把b设置为false但是主线程无法退出

private static boolean b = true;
public static void main(String[] args) throws InterruptedException {
    new Thread(()->{
        try {
            TimeUnit.SECONDS.sleep(3);
        } catch (InterruptedException e) { }
        b =false;
    }).start();
    while (b) {
        TimeUnit.MILLISECONDS.sleep(0);
    }
    System.out.println("done");
}

其实,这是可见性的问题。

虽然另一个线程把b设置为了false但是这个字段在CPU缓存中另一个线程主线程还是读不到最新的值。使用volatile关键字可以让数据刷新到主内存中去。准确来说让数据刷新到主内存中去是两件事情

  1. 将当前处理器缓存行的数据,写回到系统内存;
  2. 这个写回内存的操作会导致其他CPU里缓存了该内存地址的数据变为无效。

当然使用AtomicBoolean等关键字来修改变量b也行。但相比volatile来说AtomicBoolean等关键字除了确保可见性还提供了CAS方法具有更多的功能在本例的场景中用不到。

**问题2**关于代码加锁还有两个坑,一是加锁和释放没有配对的问题,二是分布式锁自动释放导致的重复逻辑执行的问题。你有什么方法来发现和解决这两个问题吗?

针对加解锁没有配对的问题我们可以用一些代码质量工具或代码扫描工具比如Sonar来帮助排查。这个问题在编码阶段就能发现。

针对分布式锁超时自动释放问题可以参考Redisson的RedissonLock的锁续期机制。锁续期是每次续一段时间比如30秒然后10秒执行一次续期。虽然是无限次续期但即使客户端崩溃了也没关系不会无限期占用锁因为崩溃后无法自动续期自然最终会超时。

03 | 线程池:业务代码最常用也最容易犯错的组件

**问题1**在讲线程池的管理策略时我们提到,或许一个激进创建线程的弹性线程池更符合我们的需求,你能给出相关的实现吗?实现后再测试一下,是否所有的任务都可以正常处理完成呢?

答:我们按照文中提到的两个思路来实现一下激进线程池:

  1. 由于线程池在工作队列满了无法入队的情况下会扩容线程池,那么我们可以重写队列的 offer 方法,造成这个队列已满的假象;
  2. 由于我们 Hack 了队列,在达到了最大线程后势必会触发拒绝策略,那么我们还需要实现一个自定义的拒绝策略处理程序,这个时候再把任务真正插入队列。

完整的实现代码以及相应的测试代码如下:

@GetMapping("better")
public int better() throws InterruptedException {
    //这里开始是激进线程池的实现
    BlockingQueue<Runnable> queue = new LinkedBlockingQueue<Runnable>(10) {
        @Override
        public boolean offer(Runnable e) {
            //先返回false造成队列满的假象让线程池优先扩容
            return false;
        }
    };

    ThreadPoolExecutor threadPool = new ThreadPoolExecutor(
            2, 5,
            5, TimeUnit.SECONDS,
            queue, new ThreadFactoryBuilder().setNameFormat("demo-threadpool-%d").get(), (r, executor) -> {
        try {
            //等出现拒绝后再加入队列
            //如果希望队列满了阻塞线程而不是抛出异常那么可以注释掉下面三行代码修改为executor.getQueue().put(r);
            if (!executor.getQueue().offer(r, 0, TimeUnit.SECONDS)) {
                throw new RejectedExecutionException("ThreadPool queue full, failed to offer " + r.toString());
            }
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
    });
    //激进线程池实现结束

    printStats(threadPool);
    //每秒提交一个任务每个任务耗时10秒执行完成一共提交20个任务

    //任务编号计数器
    AtomicInteger atomicInteger = new AtomicInteger();

    IntStream.rangeClosed(1, 20).forEach(i -> {
        try {
            TimeUnit.SECONDS.sleep(1);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        int id = atomicInteger.incrementAndGet();
        try {
            threadPool.submit(() -> {
                log.info("{} started", id);
                try {
                    TimeUnit.SECONDS.sleep(10);
                } catch (InterruptedException e) {
                }
                log.info("{} finished", id);
            });
        } catch (Exception ex) {
            log.error("error submitting task {}", id, ex);
            atomicInteger.decrementAndGet();
        }
    });

    TimeUnit.SECONDS.sleep(60);
    return atomicInteger.intValue();
}

使用这个激进的线程池可以处理完这20个任务因为我们优先开启了更多线程来处理任务。

[10:57:16.092] [demo-threadpool-4] [INFO ] [o.g.t.c.t.t.ThreadPoolOOMController:157 ] - 20 finished
[10:57:17.062] [pool-8-thread-1] [INFO ] [o.g.t.c.t.t.ThreadPoolOOMController:22  ] - =========================
[10:57:17.062] [pool-8-thread-1] [INFO ] [o.g.t.c.t.t.ThreadPoolOOMController:23  ] - Pool Size: 5
[10:57:17.062] [pool-8-thread-1] [INFO ] [o.g.t.c.t.t.ThreadPoolOOMController:24  ] - Active Threads: 0
[10:57:17.062] [pool-8-thread-1] [INFO ] [o.g.t.c.t.t.ThreadPoolOOMController:25  ] - Number of Tasks Completed: 20
[10:57:17.062] [pool-8-thread-1] [INFO ] [o.g.t.c.t.t.ThreadPoolOOMController:26  ] - Number of Tasks in Queue: 0
[10:57:17.062] [pool-8-thread-1] [INFO ] [o.g.t.c.t.t.ThreadPoolOOMController:28  ] - =========================

**问题2**在讲“务必确认清楚线程池本身是不是复用”时我们改进了ThreadPoolHelper使其能够返回复用的线程池。如果我们不小心每次都创建了这样一个自定义的线程池10核心线程50最大线程2秒回收的反复执行测试接口线程最终可以被回收吗会出现OOM问题吗

会因为创建过多线程导致OOM因为默认情况下核心线程不会回收并且ThreadPoolExecutor也回收不了。

我们可以看看它的源码工作线程Worker是内部类只要它活着换句话说就是线程在跑就会阻止ThreadPoolExecutor回收

public class ThreadPoolExecutor extends AbstractExecutorService {
    private final class Worker
        extends AbstractQueuedSynchronizer
        implements Runnable 
        { 
        }
 }

因此我们不能认为ThreadPoolExecutor没有引用就能回收。

04 | 连接池:别让连接池帮了倒忙

**问题1**有了连接池之后获取连接是从连接池获取没有足够连接时连接池会创建连接。这时获取连接操作往往有两个超时时间一个是从连接池获取连接的最长等待时间通常叫作请求连接超时connectRequestTimeout或连接等待超时connectWaitTimeout一个是连接池新建TCP连接三次握手的连接超时通常叫作连接超时connectTimeout。针对JedisPool、Apache HttpClient和Hikari数据库连接池你知道如何设置这2个参数吗

假设我们希望设置连接超时5s、请求连接超时10s下面我来演示下如何配置Hikari、Jedis和HttpClient的两个超时参数。

针对Hikari设置两个超时时间的方式是修改数据库连接字符串中的connectTimeout属性和配置文件中的hikari配置的connection-timeout

spring.datasource.hikari.connection-timeout=10000

spring.datasource.url=jdbc:mysql://localhost:6657/common_mistakes?connectTimeout=5000&characterEncoding=UTF-8&useSSL=false&rewriteBatchedStatements=true

针对Jedis是设置JedisPoolConfig的MaxWaitMillis属性和设置创建JedisPool时的timeout属性

JedisPoolConfig config = new JedisPoolConfig();
config.setMaxWaitMillis(10000);
try (JedisPool jedisPool = new JedisPool(config, "127.0.0.1", 6379, 5000);
     Jedis jedis = jedisPool.getResource()) {
    return jedis.set("test", "test");
}

针对HttpClient是设置RequestConfig的ConnectionRequestTimeout和ConnectTimeout属性

RequestConfig requestConfig = RequestConfig.custom()
        .setConnectTimeout(5000)
        .setConnectionRequestTimeout(10000)
        .build();
HttpGet httpGet = new HttpGet("http://127.0.0.1:45678/twotimeoutconfig/test");
httpGet.setConfig(requestConfig);
try (CloseableHttpResponse response = httpClient.execute(httpGet)) {
    return EntityUtils.toString(response.getEntity());
} catch (Exception ex) {
    ex.printStackTrace();
}
return null;

也可以直接参考我放在GitHub上的源码。

**问题2**对于带有连接池的SDK的使用姿势最主要的是鉴别其内部是否实现了连接池如果实现了连接池要尽量复用Client。对于NoSQL中的MongoDB来说使用MongoDB Java驱动时MongoClient类应该是每次都创建还是复用呢你能否在官方文档中找到答案呢?

答:官方文档里有这么一段话:

Typically you only create one MongoClient instance for a given MongoDB deployment (e.g. standalone, replica set, or a sharded cluster) and use it across your application. However, if you do create multiple instances:
All resource usage limits (e.g. max connections, etc.) apply per MongoClient instance.
To dispose of an instance, call MongoClient.close() to clean up resources.

MongoClient类应该尽可能复用一个MongoDB部署只使用一个MongoClient不过复用不等于在任何情况下就只用一个。正如文档里所说每一个MongoClient示例有自己独立的资源限制。

05 | HTTP调用你考虑到超时、重试、并发了吗

**问题1**在“配置连接超时和读取超时参数的学问”这一节中我们强调了要注意连接超时和读取超时参数的配置大多数的HTTP客户端也都有这两个参数。有读就有写但为什么我们很少看到“写入超时”的概念呢

其实写入操作只是将数据写入TCP的发送缓冲区已经发送到网络的数据依然需要暂存在发送缓冲区中只有收到对方的ack后操作系统内核才从缓冲区中清除这一部分数据为后续发送数据腾出空间。

如果接收端从socket读取数据的速度太慢可能会导致发送端发送缓冲区满导致写入操作阻塞产生写入超时。但是因为有滑动窗口的控制通常不太容易发生发送缓冲区满导致写入超时的情况。相反读取超时包含了服务端处理数据执行业务逻辑的时间所以读取超时是比较容易发生的。

这也就是为什么我们一般都会比较重视读取超时而不是写入超时的原因了。

**问题2**除了Ribbon的AutoRetriesNextServer重试机制Nginx也有类似的重试功能。你了解Nginx相关的配置吗

关于Nginx的重试功能你可以参考这里了解下Nginx的proxy_next_upstream配置。

proxy_next_upstream用于指定在什么情况下Nginx会将请求转移到其他服务器上。其默认值是proxy_next_upstream error timeout即发生网络错误以及超时才会重试其他服务器。也就是说默认情况下服务返回500状态码是不会重试的。

如果我们想在请求返回500状态码时也进行重试可以配置

proxy_next_upstream error timeout http_500;

需要注意的是proxy_next_upstream配置中有一个选项non_idempotent一定要小心开启。通常情况下如果请求使用非等幂方法POST、PATCH请求失败后不会再到其他服务器进行重试。但是加上non_idempotent这个选项后即使是非幂等请求类型例如POST请求发生错误后也会重试。

06 | 20%的业务代码的Spring声明式事务可能都没处理正确

**问题1**考虑到Demo的简洁这一讲中所有数据访问使用的都是Spring Data JPA。国内大多数互联网业务项目是使用MyBatis进行数据访问的使用MyBatis配合Spring的声明式事务也同样需要注意这一讲中提到的这些点。你可以尝试把今天的Demo改为MyBatis做数据访问实现看看日志中是否可以体现出这些坑

使用mybatis-spring-boot-starter无需做任何配置即可使MyBatis整合Spring的声明式事务。在GitHub上的课程源码我更新了一个使用MyBatis配套嵌套事务的例子实现的效果是主方法出现异常子方法的嵌套事务也会回滚。

我来和你解释下这个例子中的核心代码:

@Transactional
public void createUser(String name) {
    createMainUser(name);
    try {
        subUserService.createSubUser(name);
    } catch (Exception ex) {
        log.error("create sub user error:{}", ex.getMessage());
    }
    //如果createSubUser是NESTED模式这里抛出异常会导致嵌套事务无法“提交”
    throw new RuntimeException("create main user error");
}

子方法使用了NESTED事务传播模式

@Transactional(propagation = Propagation.NESTED)
public void createSubUser(String name) {
    userDataMapper.insert(name, "sub");
}

执行日志如下图所示:

每个NESTED事务执行前会将当前操作保存下来叫做savepoint保存点。NESTED事务在外部事务提交以后自己才会提交如果当前NESTED事务执行失败则回滚到之前的保存点。

**问题2**在讲“小心 Spring 的事务可能没有生效”时我们提到如果要针对private方法启用事务动态代理方式的AOP不可行需要使用静态织入方式的AOP也就是在编译期间织入事务增强代码可以配置Spring框架使用AspectJ来实现AOP。你能否参阅Spring的文档“Using @Transactional with AspectJ”试试呢注意AspectJ配合lombok使用还可能会踩一些坑。

我们需要加入aspectj的依赖和配置aspectj-maven-plugin插件并且需要设置Spring开启AspectJ事务管理模式。具体的实现方式包括如下4步。

第一步引入spring-aspects依赖

<dependency>
    <groupId>org.springframework</groupId>
    <artifactId>spring-aspects</artifactId>
</dependency>

第二步加入lombok和aspectj插件

<plugin>
    <groupId>org.projectlombok</groupId>
    <artifactId>lombok-maven-plugin</artifactId>
    <version>1.18.0.0</version>
    <executions>
        <execution>
            <phase>generate-sources</phase>
            <goals>
                <goal>delombok</goal>
            </goals>
        </execution>
    </executions>
    <configuration>
        <addOutputDirectory>false</addOutputDirectory>
        <sourceDirectory>src/main/java</sourceDirectory>
    </configuration>
</plugin>
<plugin>
    <groupId>org.codehaus.mojo</groupId>
    <artifactId>aspectj-maven-plugin</artifactId>
    <version>1.10</version>
    <configuration>
        <complianceLevel>1.8</complianceLevel>
        <source>1.8</source>
        <aspectLibraries>
            <aspectLibrary>
                <groupId>org.springframework</groupId>
                <artifactId>spring-aspects</artifactId>
            </aspectLibrary>
        </aspectLibraries>
    </configuration>
    <executions>
        <execution>
            <goals>
                <goal>compile</goal>
                <goal>test-compile</goal>
            </goals>
        </execution>
    </executions>
</plugin>

使用delombok插件的目的是把代码中的Lombok注解先编译为代码这样AspectJ编译不会有问题同时需要设置中的sourceDirectory为delombok目录

<sourceDirectory>${project.build.directory}/generated-sources/delombok</sourceDirectory>

第三步,设置@EnableTransactionManagement注解开启事务管理走AspectJ模式

@SpringBootApplication
@EnableTransactionManagement(mode = AdviceMode.ASPECTJ)
public class CommonMistakesApplication {

第四步使用Maven编译项目编译后查看createUserPrivate方法的源码可以发现AspectJ帮我们做编译时织入Compile Time Weaving

运行程序观察日志可以发现createUserPrivate私有方法同样应用了事务出异常后事务回滚

[14:21:39.155] [http-nio-45678-exec-2] [DEBUG] [o.s.orm.jpa.JpaTransactionManager:370 ] - Creating new transaction with name [org.geekbang.time.commonmistakes.transaction.transactionproxyfailed.UserService.createUserPrivate]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT
[14:21:39.155] [http-nio-45678-exec-2] [DEBUG] [o.s.orm.jpa.JpaTransactionManager:393 ] - Opened new EntityManager [SessionImpl(1087443072<open>)] for JPA transaction
[14:21:39.158] [http-nio-45678-exec-2] [DEBUG] [o.s.orm.jpa.JpaTransactionManager:421 ] - Exposing JPA transaction as JDBC [org.springframework.orm.jpa.vendor.HibernateJpaDialect$HibernateConnectionHandle@4e16e6ea]
[14:21:39.159] [http-nio-45678-exec-2] [DEBUG] [o.s.orm.jpa.JpaTransactionManager:356 ] - Found thread-bound EntityManager [SessionImpl(1087443072<open>)] for JPA transaction
[14:21:39.159] [http-nio-45678-exec-2] [DEBUG] [o.s.orm.jpa.JpaTransactionManager:471 ] - Participating in existing transaction
[14:21:39.173] [http-nio-45678-exec-2] [DEBUG] [o.s.orm.jpa.JpaTransactionManager:834 ] - Initiating transaction rollback
[14:21:39.173] [http-nio-45678-exec-2] [DEBUG] [o.s.orm.jpa.JpaTransactionManager:555 ] - Rolling back JPA transaction on EntityManager [SessionImpl(1087443072<open>)]
[14:21:39.176] [http-nio-45678-exec-2] [DEBUG] [o.s.orm.jpa.JpaTransactionManager:620 ] - Closing JPA EntityManager [SessionImpl(1087443072<open>)] after transaction
[14:21:39.176] [http-nio-45678-exec-2] [ERROR] [o.g.t.c.t.t.UserService:28  ] - create user failed because invalid username!
[14:21:39.177] [http-nio-45678-exec-2] [DEBUG] [o.s.o.j.SharedEntityManagerCreator$SharedEntityManagerInvocationHandler:305 ] - Creating new EntityManager for shared EntityManager invocation

以上就是咱们这门课前6讲的思考题答案了。

关于这些题目,以及背后涉及的知识点,如果你还有哪里感觉不清楚的,欢迎在评论区与我留言,也欢迎你把今天的内容分享给你的朋友或同事,一起交流。