Day832.用面向对象思想写好并发程序 -Java 并发编程实战

Posted 阿昌喜欢吃黄桃

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Day832.用面向对象思想写好并发程序 -Java 并发编程实战相关的知识,希望对你有一定的参考价值。

用面向对象思想写好并发程序

Hi,我是阿昌,今天学习记录的是关于用面向对象思想写好并发程序

在工作中,发现很多同学在设计之初都是直接按照单线程的思路来写程序的,而忽略了本应该重视的并发问题;

等上线后的某天,突然发现诡异的 Bug,再历经千辛万苦终于定位到问题所在,却发现对于如何解决已经没有了思路。

面向对象思想与并发编程有关系吗?本来是没关系的,它们分属两个不同的领域,但是在 Java 语言里,这两个领域被无情地融合在一起了,好在融合的效果还是不错的:

在 Java 语言里,面向对象思想能够让并发编程变得更简单。

那如何才能用面向对象思想写好并发程序呢?可以从

  • 封装共享变量
  • 识别共享变量间的约束条件
  • 制定并发访问策略

这三个方面下手。


一、封装共享变量

并发程序,关注的一个核心问题,不过是解决多线程同时访问共享变量的问题。

Java原子性问题解决方案中,类比过球场门票的管理,现实世界里门票管理的一个核心问题是:所有观众只能通过规定的入口进入,否则检票就形同虚设。

在编程世界这个问题也很重要,编程领域里面对于共享变量的访问路径就类似于球场的入口,必须严格控制。

好在有了面向对象思想,对共享变量的访问路径可以轻松把控。

面向对象思想里面有一个很重要的特性是封装,封装的通俗解释就是将属性和实现细节封装在对象内部,外界对象只能通过目标对象提供的公共方法来间接访问这些内部属性,这和门票管理模型匹配度相当的高,球场里的座位就是对象属性,球场入口就是对象的公共方法。

把共享变量作为对象的属性,那对于共享变量的访问路径就是对象的公共方法,所有入口都要安排检票程序就相当于前面提到的并发访问策略。

利用面向对象思想写并发程序的思路,其实就这么简单:将共享变量作为对象属性封装在内部,对所有公共方法制定并发访问策略。

就拿很多统计程序都要用到计数器来说,下面的计数器程序共享变量只有一个,就是 value,把它作为 Counter 类的属性,并且将两个公共方法 get()addOne()声明为同步方法,这样 Counter 类就成为一个线程安全的类了。


public class Counter 
  private long value;
  synchronized long get()
    return value;
  
  synchronized long addOne()
    return ++value;
  

当然,实际工作中,很多的场景都不会像计数器这么简单,经常要面临的情况往往是有很多的共享变量,例如,信用卡账户有卡号、姓名、身份证、信用额度、已出账单、未出账单等很多共享变量。

这么多的共享变量,如果每一个都考虑它的并发安全问题,那就累死了。

但其实仔细观察,你会发现,很多共享变量的值是不会变的,例如信用卡账户的卡号、姓名、身份证。对于这些不会发生变化的共享变量,建议用 final 关键字来修饰。

这样既能避免并发问题,也能很明了地表明你的设计意图,让后面接手你程序的兄弟知道,你已经考虑过这些共享变量的并发安全问题了。


二、识别共享变量间的约束条件

识别共享变量间的约束条件非常重要。

因为这些约束条件,决定了并发访问策略。

例如,库存管理里面有个合理库存的概念,库存量不能太高,也不能太低,它有一个上限和一个下限。

关于这些约束条件,我们可以用下面的程序来模拟一下。在类 SafeWM 中,声明了两个成员变量 upper 和 lower,分别代表库存上限和库存下限,这两个变量用了 AtomicLong 这个原子类,原子类是线程安全的,所以这两个成员变量的 set 方法就不需要同步了。


public class SafeWM 
  // 库存上限
  private final AtomicLong upper =
        new AtomicLong(0);
  // 库存下限
  private final AtomicLong lower =
        new AtomicLong(0);
  // 设置库存上限
  void setUpper(long v)
    upper.set(v);
  
  // 设置库存下限
  void setLower(long v)
    lower.set(v);
  
  // 省略其他业务代码

虽说上面的代码是没有问题的,但是忽视了一个约束条件,就是库存下限要小于库存上限,这个约束条件能够直接加到上面的 set 方法上吗?先直接加一下看看效果(如下面代码所示)。

在 setUpper() 和 setLower() 中增加了参数校验,这乍看上去好像是对的,但其实存在并发问题,问题在于存在竞态条件。

这里我顺便插一句,其实当你看到代码里出现 if 语句的时候,就应该立刻意识到可能存在竞态条件

假设库存的下限和上限分别是 (2,10),线程 A 调用 setUpper(5) 将上限设置为 5,线程 B 调用 setLower(7) 将下限设置为 7,如果线程 A 和线程 B 完全同时执行,会发现线程 A 能够通过参数校验,因为这个时候,下限还没有被线程 B 设置,还是 2,而 5>2;

线程 B 也能够通过参数校验,因为这个时候,上限还没有被线程 A 设置,还是 10,而 7<10。

当线程 A 和线程 B 都通过参数校验后,就把库存的下限和上限设置成 (7, 5) 了,显然此时的结果是不符合库存下限要小于库存上限这个约束条件的。


public class SafeWM 
  // 库存上限
  private final AtomicLong upper =
        new AtomicLong(0);
  // 库存下限
  private final AtomicLong lower =
        new AtomicLong(0);
  // 设置库存上限
  void setUpper(long v)
    // 检查参数合法性
    if (v < lower.get()) 
      throw new IllegalArgumentException();
    
    upper.set(v);
  
  // 设置库存下限
  void setLower(long v)
    // 检查参数合法性
    if (v > upper.get()) 
      throw new IllegalArgumentException();
    
    lower.set(v);
  
  // 省略其他业务代码

在没有识别出库存下限要小于库存上限这个约束条件之前,制定的并发访问策略是利用原子类,但是这个策略,完全不能保证库存下限要小于库存上限这个约束条件。

所以说,在设计阶段,一定要识别出所有共享变量之间的约束条件,如果约束条件识别不足,很可能导致制定的并发访问策略南辕北辙。

共享变量之间的约束条件,反映在代码里,基本上都会有 if 语句,所以,一定要特别注意竞态条件。


三、制定并发访问策略

制定并发访问策略,是一个非常复杂的事情。

应该说整个专栏都是在尝试搞定它。不过从方案上来看,

无外乎就是以下“三件事”。

  1. 避免共享:避免共享的技术主要是利于线程本地存储以及为每个任务分配独立的线程。
  2. 不变模式:这个在 Java 领域应用的很少,但在其他领域却有着广泛的应用,例如 Actor 模式、CSP 模式以及函数式编程的基础都是不变模式。
  3. 管程及其他同步工具:Java 领域万能的解决方案是管程,但是对于很多特定场景,使用 Java 并发包提供的读写锁、并发容器等同步工具会更好。

宏观原则,这些原则主要有以下三条。

  1. 优先使用成熟的工具类:Java SDK 并发包里提供了丰富的工具类,基本上能满足你日常的需要,建议你熟悉它们,用好它们,而不是自己再“发明轮子”,毕竟并发工具类不是随随便便就能发明成功的。
  2. 迫不得已时才使用低级的同步原语:低级的同步原语主要指的是 synchronized、Lock、Semaphore 等,这些虽然感觉简单,但实际上并没那么简单,一定要小心使用。
  3. 避免过早优化:安全第一,并发程序首先要保证安全,出现性能瓶颈后再优化。在设计期和开发期,很多人经常会情不自禁地预估性能的瓶颈,并对此实施优化,但残酷的现实却是:性能瓶颈不是你想预估就能预估的。

四、总结

利用面向对象思想编写并发程序,一个关键点就是利用面向对象里的封装特性,由于篇幅原因,这里只做了简单介绍,详细的可以借助相关资料定向学习。

而对共享变量进行封装,要避免“逸出”,所谓“逸出”简单讲就是共享变量逃逸到对象的外面,比如在《Happens-Before 规则》那一篇讲过构造函数里的 this“逸出”。这些都是必须要避免的。


类 SafeWM 不满足库存下限要小于库存上限这个约束条件,那你来试试修改一下,让它能够在并发条件下满足库存下限要小于库存上限这个约束条件。

  1. setUpper() 跟 setLower() 都加上 “synchronized” 关键字。不要太在意性能,避免过早优化。
  2. 如果性能有问题,可以把 lower 跟 upper 两个变量封装到一个类中,例如
public class Boundary 
    private final lower;
    private final upper;
    
    public Boundary(long lower, long upper) 
        if(lower >= upper) 
            // throw exception
        
        this.lower = lower;
        this.upper = upper;
    

以上是关于Day832.用面向对象思想写好并发程序 -Java 并发编程实战的主要内容,如果未能解决你的问题,请参考以下文章

面向对象的程序设计(day6)

JAVA入门零基础小白教程day06-类和对象

JAVA入门零基础小白教程day06-类和对象

Java 大厂面试必刷题 Day1:何为面向对象编程的思想?面向对象三大特征是什么?

Java 大厂面试必刷题 Day1:何为面向对象编程的思想?面向对象三大特征是什么?

python之路--day18--面向对象编程--类和对象