Day846.并发工具类一些问题 -Java 并发编程实战

Posted 阿昌喜欢吃黄桃

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Day846.并发工具类一些问题 -Java 并发编程实战相关的知识,希望对你有一定的参考价值。

并发工具类一些问题

Hi,我是阿昌,今天学习记录的是关于并发工具类一些问题的内容。

关于Java SDK 提供的并发工具类(JUC),这些工具类都是久经考验的,所以学好用好它们对于解决并发问题非常重要。

在介绍这些工具类的时候,重点介绍了这些工具类的产生背景、应用场景以及实现原理,目的就是在面对并发问题的时候,有思路,有办法。只有思路、办法有了,才谈得上开始动手解决问题。当然了,只有思路和办法还不足以把问题解决,最终还是要动手实践的,觉得在实践中有两方面的问题需要重点关注:细节问题与最佳实践

千里之堤毁于蚁穴,细节虽然不能保证成功,但是可以导致失败,所以一直都强调要关注细节。而最佳实践是前人的经验总结,可以帮助不要阴沟里翻船,所以没有十足的理由,一定要遵守。


一、while(true) 总不让人省心

通过破坏不可抢占条件来避免死锁问题,但是它的实现中有一个致命的问题,那就是:

while(true) 没有 break 条件,从而导致了死循环。

除此之外,这个实现虽然不存在死锁问题,但还是存在活锁问题的,解决活锁问题很简单,只需要随机等待一小段时间就可以了。

修复后的代码如下所示,仅仅修改了两个地方,一处是转账成功之后 break,另一处是在 while 循环体结束前增加了Thread.sleep(随机时间)。

class Account 
  private int balance;
  private final Lock lock = new ReentrantLock();
  // 转账
  void transfer(Account tar, int amt)
    while (true) 
      if(this.lock.tryLock()) 
        try 
          if (tar.lock.tryLock()) 
            try 
              this.balance -= amt;
              tar.balance += amt;
              //新增:退出循环
              break;
             finally 
              tar.lock.unlock();
            
          //if
         finally 
          this.lock.unlock();
        
      //if
      //新增:sleep一个随机时间避免活锁
      Thread.sleep(随机时间);
    //while
  //transfer

while(true) 问题还是比较容易看出来的,但不是所有的 while(true) 问题都这么显而易见的,很多都隐藏得比较深。

例如,原子类中的最后问题,本质上也是一个 while(true),不过它隐藏得就比较深了。

看上去 while(!rf.compareAndSet(or, nr)) 是有终止条件的,而且跑单线程测试一直都没有问题。

实际上却存在严重的并发问题,问题就出在对 or 的赋值在 while 循环之外,这样每次循环 or 的值都不会发生变化,所以一旦有一次循环 rf.compareAndSet(or, nr) 的值等于 false,那之后无论循环多少次,都会等于 false。也就是说在特定场景下,变成了 while(true) 问题。

既然找到了原因,修改就很简单了,只要把对 or 的赋值移到 while 循环之内就可以了,修改后的代码如下所示:


public class SafeWM 
  class WMRange
    final int upper;
    final int lower;
    WMRange(int upper,int lower)
    //省略构造函数实现
    
  
  final AtomicReference<WMRange>
    rf = new AtomicReference<>(
      new WMRange(0,0)
    );
  // 设置库存上限
  void setUpper(int v)
    WMRange nr;
    WMRange or;
    //原代码在这里
    //WMRange or=rf.get();
    do
      //移动到此处
      //每个回合都需要重新获取旧值
      or = rf.get();
      // 检查参数合法性
      if(v < or.lower)
        throw new IllegalArgumentException();
      
      nr = new WMRange(v, or.lower);
    while(!rf.compareAndSet(or, nr));
  


二、signalAll() 总让人省心

关于 signal() 和 signalAll() 的,Dubbo 最近已经把 signal() 改成 signalAll() 了,觉得用 signal() 也不能说错,但的确是用 signalAll() 会更安全。

推荐是使用 signalAll(),因为我们写程序,不是做数学题,而是在搞工程,工程中会有很多不稳定的因素,更有很多你预料不到的情况发生,所以不要让你的代码铤而走险,尽量使用更稳妥的方案和设计。

Dubbo 修改后的相关代码如下所示:


// RPC结果返回时调用该方法   
private void doReceived(Response res) 
  lock.lock();
  try 
    response = res;
    done.signalAll();
   finally 
    lock.unlock();
  


三、Semaphore 需要锁中锁

对象池的例子中 Vector 能否换成 ArrayList,答案是不可以的。

Semaphore 可以允许多个线程访问一个临界区,那就意味着可能存在多个线程同时访问 ArrayList,而 ArrayList 不是线程安全的,所以对象池的例子中是不能够将 Vector 换成 ArrayList 的。

Semaphore 允许多个线程访问一个临界区,这也是一把双刃剑,当多个线程进入临界区时,如果需要访问共享变量就会存在并发问题,所以必须加锁,也就是说 Semaphore 需要锁中锁。


四、锁的申请和释放要成对出现

Bug 出在没有正确地释放锁。

锁的申请和释放要成对出现,对此有一个最佳实践,就是使用 tryfinally,但是 tryfinally并不能解决所有锁的释放问题。

比如示例代码中,锁的升级会生成新的 stamp ,而 finally 中释放锁用的是锁升级前的 stamp,本质上这也属于锁的申请和释放没有成对出现,只是它隐藏得有点深。

解决这个问题倒也很简单,只需要对 stamp 重新赋值就可以了,修复后的代码如下所示:


private double x, y;
final StampedLock sl = new StampedLock();
// 存在问题的方法
void moveIfAtOrigin(double newX, double newY)
 long stamp = sl.readLock();
 try 
  while(x == 0.0 && y == 0.0)
    long ws = sl.tryConvertToWriteLock(stamp);
    if (ws != 0L) 
      //问题出在没有对stamp重新赋值
      //新增下面一行
      stamp = ws;
      x = newX;
      y = newY;
      break;
     else 
      sl.unlockRead(stamp);
      stamp = sl.writeLock();
    
  
  finally 
  //此处unlock的是stamp
  sl.unlock(stamp);


五、回调总要关心执行线程是谁

CyclicBarrier 的回调函数使用了一个固定大小为 1 的线程池,是否合理?

是合理的,可以从以下两个方面来分析。

  • 第一个是线程池大小是 1,只有 1 个线程,主要原因是 check() 方法的耗时比 getPOrders() 和 getDOrders() 都要短,所以没必要用多个线程,同时单线程能保证访问的数据不存在并发问题。
  • 第二个是使用了线程池,如果不使用,直接在回调函数里调用 check() 方法是否可以呢?绝对不可以。为什么呢?这个要分析一下回调函数和唤醒等待线程之间的关系。

下面是 CyclicBarrier 相关的源码,通过源码会发现 CyclicBarrier 是同步调用回调函数之后才唤醒等待的线程,如果我们在回调函数里直接调用 check() 方法,那就意味着在执行 check() 的时候,是不能同时执行 getPOrders() 和 getDOrders() 的,这样就起不到提升性能的作用。


try 
  //barrierCommand是回调函数
  final Runnable command = barrierCommand;
  //调用回调函数
  if (command != null)
  command.run();
  ranAction = true;
  //唤醒等待的线程
  nextGeneration();
  return 0;
 finally 
  if (!ranAction)
  breakBarrier();

所以,当遇到回调函数的时候,应该本能地问自己:执行回调函数的线程是哪一个?这个在多线程场景下非常重要。

因为不同线程 ThreadLocal 里的数据是不同的,有些框架比如 Spring 就用 ThreadLocal 来管理事务,如果不清楚回调函数用的是哪个线程,很可能会导致错误的事务管理,并最终导致数据不一致。CyclicBarrier 的回调函数究竟是哪个线程执行的呢?

如果你分析源码,会发现执行回调函数的线程是将 CyclicBarrier 内部计数器减到 0 的那个线程。

所以前面讲执行 check() 的时候,是不能同时执行 getPOrders() 和 getDOrders(),因为执行这两个方法的线程一个在等待,一个正在忙着执行 check()。

再次强调一下:当看到回调函数的时候,一定问一问执行回调函数的线程是谁。


六、共享线程池:有福同享就要有难同当

没有异常处理、逻辑不严谨等等,不过更想关注的是:findRuleByJdbc() 这个方法隐藏着一个阻塞式 I/O,这意味着会阻塞调用线程。

默认情况下所有的 CompletableFuture 共享一个 ForkJoinPool,当有阻塞式 I/O 时,可能导致所有的 ForkJoinPool 线程都阻塞,进而影响整个系统的性能。


//采购订单
PurchersOrder po;
CompletableFuture<Boolean> cf = CompletableFuture.supplyAsync(()->
    //在数据库中查询规则
    return findRuleByJdbc();
  ).thenApply(r -> 
    //规则校验
    return check(po, r);
);
Boolean isOk = cf.join();

利用共享,往往能让快速实现功能,所谓是有福同享,但是代价就是有难要同当。

在强调高可用的今天,大多数人更倾向于使用线程池隔离的方案。


七、线上问题定位的利器:线程栈 dump

ReadWriteLock并发容器中最后的问题,本质上都是定位线上并发问题,方案很简单,就是通过查看线程栈来定位问题。

重点是查看线程状态,分析线程进入该状态的原因是否合理,参考Java 线程的生命周期来加深理解。

为了便于分析定位线程问题,需要给线程赋予一个有意义的名字,对于线程池可以通过自定义 ThreadFactory 来给线程池中的线程赋予有意义的名字,也可以在执行 run() 方法时通过Thread.currentThread().setName();

来给线程赋予一个更贴近业务的名字。


以上是关于Day846.并发工具类一些问题 -Java 并发编程实战的主要内容,如果未能解决你的问题,请参考以下文章

Python Day41 socketserver实现并发

Day298.并发容器&并发队列 -Juc

并发编程—2并发工具类

并发工具类控制并发线程的数量 Semphore

并发编程--线程的并发工具类

并发工具类 Phaser类