java线程安全工作思考

背景
我们在背八股文的时候肯定背到过和线程安全有关的知识,但是理解并不深,直到在公司的项目中看到了和线程安全有关的类ConcurrentHashMap等又引起了我的思考。
问题
什么时候应该用这些和线程有关的类呢?为什么我们平常开发中用不到呢?
思考
对此我进行了大致的思考:线程安全解决的是,线程并发时争抢共享资源的问题。所以在开发中要对共享资源进行操作的话就需要考虑是否存在线程不安全的问题。
对比
例如:项目中用到ConcurrentHashMap的代码片段

/**
 * 初始化配置中心Nacos连接会话信息
 */
@PostConstruct
public void init() {
    List<ConfigCenterTenantEnvNacosProperties.TenantEnvNacosInfo> confTenantEnvNacosInfoList =
            tenantEnvNacosProperties.getTenantEnvNacosInfoList();
    if (CollectionUtils.isEmpty(confTenantEnvNacosInfoList)) {
        return;
    }

    tenantConfNacosServerListMgrMap = new ConcurrentHashMap<>();
    tenantConfNacosSecurityProxyMap = new ConcurrentHashMap<>();
    tenantConfNacosServerUrlMap = new ConcurrentHashMap<>();
    tenantRegNacosServerListMgrMap = new ConcurrentHashMap<>();
    tenantRegNacosSecurityProxyMap = new ConcurrentHashMap<>();
    tenantRegNacosServerUrlMap = new ConcurrentHashMap<>();

    NacosRestTemplate nacosRestTemplate = ConfigHttpClientManager.getInstance().getNacosRestTemplate();
    confTenantEnvNacosInfoList.stream()
            .forEach(t -> {
                Properties properties = new Properties();
                properties.put(PropertyKeyConst.SERVER_ADDR, t.getNacosServerAddr());
                properties.put(PropertyKeyConst.USERNAME, t.getUserName());
                properties.put(PropertyKeyConst.PASSWORD, t.getPassword());

                try {
                    String tenantConfKey = String.format("%s-%s", t.getTenantId(), t.getEnv());
                    tenantConfNacosServerListMgrMap.put(tenantConfKey, new ServerListManager(properties));
                    tenantConfNacosSecurityProxyMap.put(tenantConfKey, new SecurityProxy(properties, nacosRestTemplate));
                    tenantConfNacosServerUrlMap.put(tenantConfKey, new ConcurrentHashMap<>(4));
                } catch (NacosException e) {
                    log.error("Load config center Nacos config data list by tenant and env failed!", e);
                }
            });


    regCenterTenantEnvNacosProperties.getTenantEnvNacosInfoList().stream().forEach(t -> {
        Properties regTokenPropsMap = new Properties();
        regTokenPropsMap.put(PropertyKeyConst.SERVER_ADDR, t.getNacosServerAddr());
        regTokenPropsMap.put(PropertyKeyConst.USERNAME, t.getUserName());
        regTokenPropsMap.put(PropertyKeyConst.PASSWORD, t.getPassword());

        try {
            tenantRegNacosServerListMgrMap.put(String.format("%s-%s-SvrListMgr", t.getTenantId(), t.getEnv()),
                    new ServerListManager(regTokenPropsMap));
            tenantRegNacosSecurityProxyMap.put(String.format("%s-%s-SecurityProxy", t.getTenantId(), t.getEnv()),
                    new SecurityProxy(regTokenPropsMap, nacosRestTemplate));
            tenantRegNacosServerUrlMap.put(String.format("%s-%s-SvrUrls", t.getTenantId(), t.getEnv()),
                    new ConcurrentHashMap<>(4));
        } catch (NacosException e) {
            log.error("Load registry center Nacos config data list by tenant and env failed!", e);
        }
    });

    // 设定定时线程刷新配置中心Nacos登录状态保持Token不过期
    this.scheduledExecutorService = new ScheduledThreadPoolExecutor(1, r -> {
        Thread t = new Thread(r);
        t.setName("NacosConsoleService.configCenter.security.updater");
        t.setDaemon(true);
        return t;
    });

    this.scheduledExecutorService.scheduleWithFixedDelay(() -> {
        tenantConfNacosSecurityProxyMap.entrySet().stream()
                .filter(sp -> StringUtils.isNotBlank(sp.getKey())
                        && sp.getValue() != null && tenantConfNacosServerListMgrMap.get(sp.getKey()) != null)
                .forEach(sp -> {
                    sp.getValue().login(tenantConfNacosServerListMgrMap.get(sp.getKey()).getServerUrls());
                });

        tenantRegNacosSecurityProxyMap.entrySet().stream()
                .filter(sp -> StringUtil.isNotBlank(sp.getKey())
                        && sp.getValue() != null && tenantRegNacosServerListMgrMap.get(
                        sp.getKey().replace("SecurityProxy", "SvrListMgr")) != null)
                .forEach(sp -> {
                    sp.getValue().login(tenantRegNacosServerListMgrMap.get(
                            sp.getKey().replace("SecurityProxy", "SvrListMgr")).getServerUrls());
                });
    }, 0, this.securityInfoRefreshIntervalMills, TimeUnit.MILLISECONDS);
    this.initProducer();
}

tenantConfNacosServerListMgrMap
tenantConfNacosSecurityProxyMap
tenantConfNacosServerUrlMap
tenantRegNacosServerListMgrMap
tenantRegNacosSecurityProxyMap
tenantRegNacosServerUrlMap
这几个map中保存的信息都是可以被共享的,所以存在线程安全问题。因此要使用ConcurrentHashMap类。
平常我们用不到线程安全类的代码片段

@Override
public List<RouteModel> retrieve (RouteModel model, AuthModel auth) {
    GatewayRoute entity = getEntityFromModel(model);
    List<GatewayRoute> entityList = routeService.findList(entity);
    List<RouteModel> modelList = new ArrayList<>();
    for (GatewayRoute each: entityList) {
        if (!auth.isAvailable(each.getClusterId())) {
            continue;
        }
        RouteModel m = getModelFromEntity(each);
        modelList.add(m);
    }
    return modelList;
}

这里不关注线程安全的原因是因为没有对共享资源的操作,方法每次执行都是new一个新的对象,从数据库中获取数据在进行处理。
以上是我自己一些浅薄的理解。以后会继续补充的。
引申出的问题:使用Concurrent相关的类就不需要加锁了么
这里以ConcurrentHashMap为例。
其实不是这样的,ConcurrentHashMap的线程安全指的是ConcurrentHashMap中的put方法是线程安全的,也就是说在使用这两个方法时不需要额外加锁。但是这并不意味着其他使用到ConcurrentHashMap的地方都不需要加锁。
扩展
1.ConcurrentHashMap的get操作是不加锁的,怎么避免脏数据?
在1.8中ConcurrentHashMap的get操作全程不需要加锁,这也是它比其他并发集合比如hashtable、用Collections.synchronizedMap()包装的hashmap;安全效率高的原因之一。
get操作全程不需要加锁是因为Node的成员val是用volatile修饰的和数组用volatile修饰没有关系。
数组用volatile修饰主要是保证在数组扩容的时候保证可见性。
2.加锁要考虑锁的粒度和场景问题
在方法上加 synchronized 关键字实现加锁确实简单,也因此我曾看到一些业务代码中几乎所有方法都加了 synchronized,但这种滥用 synchronized 的做法:
一是,没必要。通常情况下 60% 的业务代码是三层架构,数据经过无状态的 Controller、Service、Repository 流转到数据库,没必要使用 synchronized 来保护什么数据。(这句话差不多是我上面的意思)
二是,可能会极大地降低性能。使用 Spring 框架时,默认情况下 Controller、Service、Repository 是单例的,加上 synchronized 会导致整个程序几乎就只能支持单线程,造成极大的性能问题。
即使我们确实有一些共享资源需要保护,也要尽可能降低锁的粒度,仅对必要的代码块甚至是需要保护的资源本身加锁。
如果精细化考虑了锁应用范围后,性能还无法满足需求的话,我们就要考虑另一个维度的粒度问题了,即:区分读写场景以及资源的访问冲突,考虑使用悲观方式的锁还是乐观方式的锁。
一般业务代码中,很少需要进一步考虑这两种更细粒度的锁,所以我只和你分享几个大概的结论,你可以根据自己的需求来考虑是否有必要进一步优化:
对于读写比例差异明显的场景,考虑使用 ReentrantReadWriteLock 细化区分读写锁,来提高性能。
如果你的 JDK 版本高于 1.8、共享资源的冲突概率也没那么大的话,考虑使用 StampedLock 的乐观读的特性,进一步提高性能。
JDK 里 ReentrantLock 和 ReentrantReadWriteLock 都提供了公平锁的版本,在没有明确需求的情况下不要轻易开启公平锁特性,在任务很轻的情况下开启公平锁可能会让性能下降上百倍。
3.多把锁要小心死锁问题
刚才我们聊到锁的粒度够用就好,这就意味着我们的程序逻辑中有时会存在一些细粒度的锁。但一个业务逻辑如果涉及多把锁,容易产生死锁问题。
之前我遇到过这样一个案例:批量借阅操作需要锁定档案中份数字段,拿到所有档案的锁之后进行扣减份数操作,全部操作完成之后释放所有的锁。代码上线后发现,偶发性发生借阅失败,失败后需要重新执行借阅操作,极大影响了用户体验。
接下来,我们剖析一下核心的业务代码。
首先,定义一个档案实体,包含题名、份数和档案锁三个属性,每一件档案默认份数为1000 份;然后,初始化 10 个这样的档案对象来模拟批量借阅操作:

@Data
@RequiredArgsConstructor
static class WSDA {
    final String title; //题名
    int fs= 1000; //份数
    @ToString.Exclude //ToString不包含这个字段 
    ReentrantLock lock = new ReentrantLock();
}
随后,写一个方法模拟随机添加3件档案到借阅车:
private List<WSDA> createCart() {
    return IntStream.rangeClosed(1, 3)
            .mapToObj(i -> "wsda" + ThreadLocalRandom.current().nextInt(wsdaItems.size()))
            .map(name -> wsdaItems.get(name)).collect(Collectors.toList());
}

提交借阅申请代码如下:先声明一个 List 来保存所有获得的锁,然后遍历借阅车中的档案依次尝试获得档案的锁,最长等待 10 秒,获得全部锁之后再扣减份数;如果有无法获得锁的情况则解锁之前获得的所有锁,返回 false 借阅申请失败。

private boolean createBorrowOrder(List<Wsda> order) {
    //存放所有获得的锁
    List<ReentrantLock> locks = new ArrayList<>();

    for (Wsda item : order) {
        try {
            //获得锁10秒超时
            if (item.lock.tryLock(10, TimeUnit.SECONDS)) {
                locks.add(item.lock);
            } else {
                locks.forEach(ReentrantLock::unlock);
                return false;
            }
        } catch (InterruptedException e) {
        }
    }
    //锁全部拿到之后执行扣减库存业务逻辑
    try {
        order.forEach(item -> item.fs--);
    } finally {
        locks.forEach(ReentrantLock::unlock);
    }
    return true;
}

我们写一段代码测试这个借阅操作。模拟在多线程情况下进行 100 次创建借阅车和借阅申请操作,最后通过日志输出成功的次数、总剩余的档案份数、100 次创建借阅车和借阅申请操作耗时,以及申请完成后的档案份数明细:

@GetMapping("wrong")
public long wrong() {
    long begin = System.currentTimeMillis();
    //并发进行100次借阅申请操作,统计成功次数
    long success = IntStream.rangeClosed(1, 100).parallel()
            .mapToObj(i -> {
                List<Wsda> cart = createCart();
                return createBorrowOrder(cart);
            })
            .filter(result -> result)
            .count();
    log.info("success:{} totalRemaining:{} took:{}ms items:{}",
            success,
            wsdaItems.entrySet().stream().map(item -> item.getValue().fs).reduce(0, Integer::sum),
            System.currentTimeMillis() - begin, wsdaItems);
    return success;
}

可以看到,100 次申请操作成功了 65 次,10 件档案总计 10000 份,剩余总计为 9805份,消耗了 195 份符合预期(65 次申请成功,每次借阅申请包含三件档案),总耗时 50 秒。
为什么会这样呢?
使用 JDK 自带的 VisualVM 工具来跟踪一下,重新执行方法后不久就可以看到,线程 Tab 中提示了死锁问题,根据提示点击右侧线程 Dump 按钮进行线程抓取操作:

查看抓取出的线程栈,在页面中部可以看到如下日志

显然,是出现了死锁,线程 4 在等待的一个锁被线程 3 持有,线程 3 在等待的另一把锁被线程 4 持有。
那为什么会有死锁问题呢?
我们仔细回忆一下借阅车添加档案的逻辑,随机添加了三件档案,假设一个借阅车中的档案是 wsdaItem1 和 wsdaItem2,另一个借阅车中的档案是 wsdaItem2 和 wsdaItem1,一个线程先获取到了 wsdaitem1 的锁,同时另一个线程获取到了wsdaitem2 的锁,然后两个线程接下来要分别获取 wsdaitem2 和 wsdaitem1 的锁,这个时候锁已经被对方获取了,只能相互等待一直到 10 秒超时。
其实,避免死锁的方案很简单,为借阅车中的档案排一下序,让所有的线程一定是先获取 wsdaitem1 的锁然后获取 wsdaitem2 的锁,就不会有问题了。所以,我只需要修改一行代码,对 createCart 获得的借阅车按照档案题名进行排序即可:

@GetMapping("right")
public long right() {
    ...
.    
    long success = IntStream.rangeClosed(1, 100).parallel()
            .mapToObj(i -> {
                List<Wsda> cart = createCart().stream()
                        .sorted(Comparator.comparing(Wsda::getTitle))
                        .collect(Collectors.toList());
                return createBorrowOrder(cart);
            })
            .filter(result -> result)
            .count();
    ...
    return success;
}

测试一下 right 方法,不管执行多少次都是 100 次借阅申请成功,而且性能相当高,达到了 3000 以上的 TPS。
这个案例中,虽然产生了死锁问题,但因为尝试获取锁的操作并不是无限阻塞的,所以没有造成永久死锁,之后的改进就是避免循环等待,通过对借阅车的档案进行排序来实现有顺序的加锁,避免循环等待。

2023_1_13补充
背景
在项目代码中看到了有的代码加了分布式锁,有的代码没加分布式锁。就不经产生了一个疑惑:到底什么时候该加分布式锁,什么时候不加分布式锁?
与同事沟通
加分布式锁的原因:因为是分布式系统,可能同时有多个相同的请求(各方面bug导致的+实际用户的操作)打到不同的机器上,这样加了分布式锁以后就只需要处理一个这样的请求就可以了,不仅能降低系统的压力,同时了对数据库等的重复操作造成的问题。结论:如果是分布式系统理论上每个接口都应该加上分布式锁!
我们项目中有的代码加了分布式锁,有的代码没加分布式锁主要是因为:虽然是分布式系统,但是我们的系统是对内使用的,并发量并不会很大。所以不强制每个代码要加。
思考
分布式锁解决的问题和线程安全的问题类似,但是并不完全一致。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值