java深度调试技术【第四五章:多线程和幽灵代码】

前言

我是[提前退休的java猿],一名7年java开发经验的开发组长,分享工作中的各种问题!(抖音、公众号同号)

🔈PS: 最近多了一个新的写作方向,就是会把出版社寄来的【java深度调试技术】按照书的骨架提取其中干货进行丰盈写成博客,大家可以关注我的新专栏

多线程和幽灵代码

这次介绍的是第四章和第五章的内容,因为这两章的内容比较少我就把他合到一起了 ,这两章有些内容比较基础吧.

比如第四章 写了 如何加锁,如果正确的使用synchronized,以及如何实现线程的通信这些算是比较基础的知识吧。
第五章的主题是幽灵代码,介绍得也非常得基础,介绍了异常情况导致的资源泄露,比如释放资源没有在finally中执行,双检索问题。(所以这两章也是非常的基础)

幽灵代码(防御性编程)

既然书上的案例太基础了,那就自己总结一波吧

1. 数据库的时间精度问题

datetimetimestampmysql 中 默认精度是秒(不设置精度的话就会造成精度丢失),我们使用java 的时间对象保存的时候如果把毫秒带上这样对于敏感的业务,就会受到影响,因为后面的毫秒在插入数据库的时候会四舍五入😜 ,比如 当前时间 23点59m59s600ms 存到数据库就会变成 第二天0晨。

案例详情: 时间设置的是23点59分59秒,数据库却存的是第二天00:00:00

时间的坑儿挺多的,如果那种跨国业务还可能存在夏令时的影响

2.异常的处理

2.1 RabbitMQ 消费不捕捉异常

消费代码中未捕捉异常,并且不配置死信息队列和重试次数,因为在开发测试过程中异常的情况很少碰到,上了生产环境运行一段时间可能就会出现异常的情况吗,比如消费逻辑中有RPC 或者 Http 接口 出现异常等问题。这样我们的消息就会一直在队列中一直消费😁。

java 复制代码
@RabbitListener(queues = RabbitMqConstant.SMS_DELAY_QUEUE) 
public void id(Message message, Channel channel ) {
    String taskId = new String(message.getBody()); 
    log.info("-------task ID-------:{}",taskId); 
    STask sTask = taskService.getBaseMapper().selectById(id); 
    if(Objects.nonNull(sTask)){ 
        log.info("-------schedule action sTask-------:{}", sTask);
        taskService.commitTaskAfterDoSend(sTask);
    }
}

案例详情: 同事的代码问题第六期(MQ与多线程处理数据)

2.2 线程池任务不捕捉异常(CountDownLatch )

线程池任务不捕捉异常,并且使用了CountDownLatch 等到任务执行完成,如果执行过程中发生异常就会出现 主线程一直被阻塞

java 复制代码
// 伪代码--------
@Scheduled  
void countMsg(){
    if(RedisUtils.lock){
      CountDownLatch countDownLatch = new CountDownLatch(size);
        for(){
            threadPoolExecutor.execute(()->{
                //没有捕捉异常
                countMethod();
                countDownLatch.countDown();
              });
        }
      //一直阻塞
      countDownLatch.await();
      log.info("统计时间:");
      RedisUtils.releaseLock();   
    }
}

案例详情: 记录一次缓存未刷新问题使用场景

3 事务问题

事务失效的基本场景就不说了,相信大家都背得很熟了,主要说一下事务中异步问题和锁问题。

3.1 事务里面嵌套分布式锁

这个问题是很多老手都容易犯的问题,很多公司对并发测试这块要求并不严格,所以这个问题很容易蒙混过关发到生产环境

java 复制代码
@Transactional(rollbackFor = Exception.class)
public void importData2(Long importId) {
  RLock lock = redissonLockClient.getLock(RedisKeyConstant.MEMBER_IMPORT_LOCK_KEY);
  try {
    lock.lock();
    // 读取导入记录ID,获取导入文件地址,解析数据,数据校验,数据分组
    GroupData data = handleData(importId);
    //插入
    batchInsert(data.getNeedInsertData());
    //更新导入记录成功
    updateSuccessImportRecord(importId);
  } catch (Exception e){
    //表导入记录失败
    updateFailImportRecord(importId);
  }finally {
    if (lock.isHeldByCurrentThread()) {
      lock.unlock();
    }
  }
}

案例详情:同事的代码问题第一期(锁和事务的运用)

3.2事务中的异步操作

这个问题是也是经常出现,可能存在异步代码执行了,最后事务执行失败。或者是异步操作依赖事务的结果这样可能异步操作执行了时候 事务还没提交完成,导致异步执行逻辑出问题。

java 复制代码
@Transactional(rollbackFor = Exception.class)
@Override
public Respoonse<String> updateAccountRoles(BatchUpdateParam param) {
    //校验参数,
    if(!paramCheck(param)){
        return Response.fail("网络繁忙!");
    }
    // 更新账号信息。
    List updateUserList = userService.updateRoles(param);
    if (updateUserList.size() > 0) {
        // 异步通知其他系统更新对应账号信息。
        //❌事务还没提交,以及已经开始异步执行了,异步方法里面可能查询还是更新前的数据。
        userSyncUtil.asyncUserList(updateUserList);
    }
    return Respoonse.ok("批量更新成功。");
}

4.数据库操作

关于SQL的一些不规范操作或者查询今天就不说,介绍两个我在工作中遇到的两个问题吧,一个 mybatis-plus的查询和pageHelper的使用问题。

4.1 Mybatis-plus 数据查询

Wrapper 查询数据没有指定需要的字段,所有等同于 select * 这也是有些公司不用mybatis-plus的原因之一,当然Wrapper 是可以指定查询的字段的,只是开发中很多人为了方便或者都是分页查询,查询出来的数据量不大所以就没有啥问题。

错误案例(能确认list数据一定不多的话,问题倒是不大):

java 复制代码
public List<ShopAddress> selectByShopIdList(List<Integer> shopIdList) {

    Wrapper<ShopAddress> wrapper = new EntityWrapper<>();
    wrapper.in("shop_id", shopIdList);
    List<ShopAddress> shopAddressList = shopAddressMapper.selectList(wrapper); 
    return shopAddressList;

}

指定返回需要的字段

java 复制代码
shopAddressMapper.selectList(
        Wrappers.<ShopAddress>lambdaQuery()
           //返回指定字段
            .select(ShopAddress::getShopId, ShopAddress::getAddress, ShopAddress::getCity)
            .in(ShopAddress::getShopId, shopIdList)
    );
}
4.2 pageHelper 的使用

之前一个高级java开发就犯过这个问题,开启分页之后,判断条件之后直接返回空集合了,没有执行查询语句,导致这个分页信息依然和 当前线程绑定。当线程被复用时第一个SQL 就会被当成分页查询出来,出现报错等情况。

就是开启分析之后,将分页信息和当前线程绑定了,所以我们再开启分页之后,一定要保证分页SQL的执行(执行之后线程绑定的分页信息)或者把 清除线程的分页信息(调用clear方法)。

错误案例

java 复制代码
PageHelper.startPage(1,10);
//当条件不满足时,不执行
if(condition){
     List<SaBanner> banners = saBannerMapper.queryList();
}
return Collections.emptyList();

推荐阅读: 新手必看:所有的分页方式及原理

5.线程上下文变量在线程池中传递

我们系统用户授权还是走的shiro那一套,shiro 会把用用户信息 通过 InheritableThreadLocal 进行绑定到上下文中,当我们在线程池中也 通过 shiro 的上下文去获取的话,这时候线程池的用户信息就是创建线程时的父线程的用户信息了。这个问题会造成获取到的用户信息错乱的问题,在实际开发过程我也遇到过好几次。

错误使用示例: 假设我们 userContext 就是 shiro 获取用户信息的上下文

java 复制代码
static Executor executor = Executors.newSingleThreadExecutor();

public static void main(String[] args) {
 
    //1. 发起请求1:假设现在是 用户 张三 发起请求 
    userContext.set(new UserContext("u123", "张三"));
    executor.execute(()->{
        System.out.println("子线程获取到的上下文: " + userContext.get());
    });
    // 模拟请求1执行完成
    Thread.sleep(1000);
    // 2. 发起请求2:假设现在是 用户 李四 发起请求
    userContext.set(new UserContext("123", "李四"));
    System.out.println("父线程上下文: " + userContext.get());
    executor.execute(()->{
        System.out.println("子线程获取到的上下文: " + userContext.get());
    });
    // 清理资源
    userContext.remove();

}

输出如下:

js 复制代码
子线程获取到的上下文: UserContext{userId='u123', userName='张三'}
父线程上下文: UserContext{userId='123', userName='李四'}
子线程获取到的上下文: UserContext{userId='u123', userName='张三'}

我们第二次 从线程池获取用户信息,发现拿到的是第一次绑定的用户信息,这就造成用户信息错乱了

案例详情、原理分析:java程序员必须掌握的【InheritableThreadLocal

6. 缓存问题

缓存这个问题,案例就太多了,相信大家背过很多八股文了,缓存一致性问题等。今天给大家分享一个在实际开发中遇到的mybatis 一级缓存的案例:

错误示例:

java 复制代码
List ret = mapper.select(Obejct parameter); 
ret.add(1234l)
.....
//中间很多其他操作和方法,再次查询 结果就被修改了
List ret2 = mapper.select(Obejct parameter); 

7. 并发问题(分布式锁失效->数据库兜底)

这个也是非常常见的问题,今天就分享几个并发兜底的措施吧。CRUD开发中也能用上的,很多时候我们以为加了分布式锁就高枕无忧了,我遇到过几次都是因为加了分布式锁也出现了数据问题。

分布式锁过期、事务提交之后数据响应超时->异常->分布式解锁->事务实际上还在执行(此时同样的请求再进来就出错)

7.1 更新操作

数据的状态更新 以及库存的扣减,这些算是CRUD 开发中经常遇到的更新操作,但是我看了很多人写的代码都存在些小问题吧。

不完美的示例:

java 复制代码
// 事务外层加了分布式锁

@Transactional(rollbackFor = Exception.class) 
@Override public void commit(String id) { 
    User user = UserUtil.getUser(); 
    if(this.getId(id).getStatus !=1){
        thorw new RuntimeException("状态校验失败?不能提交数据");
    }
    this.update(Wrappers.lambdaUpdate(OfflineActivityEntity.class)
    .eq(OfflineActivityEntity::getId,req.getId()) 
    //更新状态 
    .set(OfflineActivityEntity::getStatus, 2);
    OfflineActivityEntity OfflineActivityEntity = activityMapper.selectById(id);
    //提交审批数据(会生成一条审批数据) 
    service.commitApprove(OfflineActivityEntity, user);
}

优化方式,更新的时候 把 修改前的预期状态条件加上,再判断更新结果,更新成功之后 再执行下一步

因为我们在根据ID更新数据的时候,会产生行锁,所以这个时候先判断条件是否满足再去更新(版本号,库存扣减都是利用了乐观锁的思想)

案例详情:报名人数超限

7.2 插入操作

数据重复插入,很多时候也只在代码层面上加了锁和查询记录是否存在的操作。但是数据库没有做相关的唯一索引(比如本身业务上规定 一个人只能存在一条数据)。

没有加唯一索引大概有两个原因,第一是信任分布式锁,第二是因为数据库的设计 不太好加唯一索引,比如数据库存在is_del 的字段,这时候加了唯一索引 数据逻辑删除之后 也不能插入正常的数据了。

如果数据正确性第一,那么我们就必须把唯一索引加上,要么用单独的表记录逻辑删除的数据;

要么逻辑删除标识上加上版本号措施,比如0正常,其他标识删除,每次删除把逻辑删除的值改成ID值,这样就可以做联合唯一索引

相关推荐
用户3074596982072 小时前
反射(Reflection)—— PHP 的“元编程之眼”
后端·php
林太白2 小时前
rust13-字典类型
后端·rust
PFinal社区_南丞2 小时前
单文件代码部署工具
后端
间彧2 小时前
DDD与传统三层架构、MVC对比
后端
间彧2 小时前
SpringBoot项目,DDD与传统的三层架构详细目录结构
后端
稚辉君.MCA_P8_Java2 小时前
深入理解 TCP;场景复现,掌握鲜为人知的细节
java·linux·网络·tcp/ip·kubernetes
熊猫比分站2 小时前
[特殊字符] Java/Vue 实现体育比分直播系统,支持多端实时更新
java·开发语言·vue.js
lang201509283 小时前
深入掌握 Maven Settings:从配置到实战
java·maven
scx_link3 小时前
修改JetBrains产品(IntelliJ IDEA 、PyCharm等软件)的默认插件和日志的存储位置
java·pycharm·intellij-idea