重学Java并发编程 (为什么要使用不可变对象?)

线程不安全的例子

举个例子,我们有一套监控系统,需要跟踪人员轨迹,如果离开某一个区域就会发生预警,这个例子中需要查询人员的最新信息,部分代码如下:

TrackPoint是一个位置信息类,包含坐标变量X和Y,和更新位置时间戳

@NotThreadSafe
public class TrackPoint {

    /**
     * 轨迹x坐标
     */
    private double x;

    /**
     * 轨迹y坐标
     */
    private double y;

    /**
     * 轨迹时间戳
     */
    private long timestamp;

    public TrackPoint(double x, double y, long timestamp) {
        this.x = x;
        this.y = y;
        this.timestamp = timestamp;
    }

    public void updatePoint(double x, double y, long timestamp) {
        this.x = x;
        this.y = y;
        this.timestamp = timestamp;
    }

    public double getX() {
        return x;
    }

    public double getY() {
        return y;
    }

    public long getTimestamp() {
        return timestamp;
    }
}

下面可以获取每个人的最新位置信息

@NotThreadSafe
public class HumanTrackLocation {

    /**
     * 人员最新轨迹信息
     */
    private final Map<String, TrackPoint> HUMAN_LOCATION = new ConcurrentHashMap<>();

    public void updateLocation(String humanId, double x, double y) {
        TrackPoint trackPoint = HUMAN_LOCATION.get(humanId);
        if (Objects.nonNull(trackPoint)) {
            trackPoint.updatePoint(x, y, System.currentTimeMillis());
            return;
        }

        trackPoint = new TrackPoint(x, y, System.currentTimeMillis());
        HUMAN_LOCATION.put(humanId, trackPoint);
    }

    public TrackPoint getLocation(String humanId) {
        return HUMAN_LOCATION.get(humanId);
    }
}

当人员的位置发生变化的时候,我们可以调用updateLocation方法来更新位置信息,另外也可以调用getLocation方法来获取人员的位置信息。

大家看上面代码会有什么问题呢?

问题在于updateLocation方法并不是一个原子的操作,可能一个线程在调用updateLocation方法时,另一个线程调用了getLocation方法,而第一个线程只更新了x的值,y值和timestamp还没有被更新,这时候TrackPoint正处于一个中间状态就被其他的线程获取了,这明显是有问题的。

线程安全性

所谓共享的资源,是指在多个线程同时对其进行访问的情况下,各线程都会使其发生变化,而线程安全性的主要目的就在于在受控的并发访问中防止数据发生变化。

当线程在没有同步的状态下读取变量时,可能会得到一个失效值,但至少这个值是由之前某个线程设置的值,而不是一个随机值。这种安全性保证也被称为最低安全性(out-of-thin-air-safety)。
需要注意的是,最低安全性适用于大多数变量,但是存在一个例外: 非volatile类型的64位数值变量(double和long)。
Java内存模型要求,变量的读取操作和写入操作都必须是原子操作,但对于非volatile类型的long和double变量,JVM允许将64位的读操作或写操作分解为两个32位的操作。当读取一个非volatile类型的long变量时,如果对该变量的读操作和写操作在不同的线程中执行,那么很可能会读取到某个值的高32位和另一个值的低32位,因此,即使不考虑失效数据的问题,在多线程程序中使用共享且可变的long和double等类型的变量也是不安全的,除非使用关键字volatile来声明它们,或者用锁保护起来。

如何满足线程的安全性?

满足同步需求可以使用锁,但无论是synchronized关键字还是显示锁Lock,都会牺牲系统的性能,还有另一种方法是使用不可变对象(Immutable Object)。我们遇到的线程一些安全问题,例如得到失效数据,丢失更新操作或者观察到某个对象处于不一致的状态等等,都与多线程试图同时访问同一个可变的状态相关。如果对象的状态不可变,那么这些问题与复杂性也就自然消失了。

什么是不可变对象?

如果某个对象在被创建后其状态就不能被修改,那么这个对象就称为不可变对象。线程安全性是不可变对象的固有属性之一,它们的不可变条件是由构造函数创建的,只有它们的状态不改变,那么这些不变性条件就可以维持。
不可变对象一定是线程安全的。

为了确保人员信息的更新具备线程安全的特性,又不想使用锁这种重量级的操作,我们可以将位置信息改造为不可变的类,如果人员的位置信息发生变化,我们可以通过替换整个Location对象来实现,而不是通过updatePoint来实现。

尝试改造之前的例子

如何将一个类改造为不可变的类呢,所谓的不可变类是指一个对象一经创建就不再改变。
我们可以通过Java的关键字final来修饰这三个字段,通过Java语言的语法特性来保证这三个字段的不可变

我们还需要思考一个问题,将x、y、timestamp用final修饰后,但是又有一个子类继承了TrackPoint并修改了get方法怎么办?
如果get方法被修改那么显然不符合不可变对象的行为,因为它的子类可以改变它的方法行为,为了避免这种情况,我们需要将TrackPoint设计为不可继承的,通过final修饰即可。

最终代码如下:

@Immutable
public final class TrackPoint {

    /**
     * 轨迹x坐标
     */
    private final double x;

    /**
     * 轨迹y坐标
     */
    private final double y;

    /**
     * 轨迹时间戳
     */
    private final long timestamp;

    public TrackPoint(double x, double y, long timestamp) {
        this.x = x;
        this.y = y;
        this.timestamp = timestamp;
    }
    
    public double getX() {
        return x;
    }

    public double getY() {
        return y;
    }

    public long getTimestamp() {
        return timestamp;
    }
}

接着如果人员的位置发生变化的时候,通过替换整个Location来表示,这样就能避免前面的问题了

@ThreadSafe
public class HumanTrackLocation {

    /**
     * 人员最新轨迹信息
     */
    private final Map<String, TrackPoint> HUMAN_LOCATION = new ConcurrentHashMap<>();

    public void updateLocation(String humanId, double x, double y) {
        HUMAN_LOCATION.put(humanId, new TrackPoint(x, y, System.currentTimeMillis()));
    }

    public TrackPoint getLocation(String humanId) {
        return HUMAN_LOCATION.get(humanId);
    }
}

由于JVM中除了long和double之外,用"="进行赋值都是原子的,所以调用HUMAN_LOCATION的get方法不会获取一个错误的值,而ConcurrentHashMap中每个kv键值对的对象Node,它的v使用了volatile修饰,也保证了可见性,一个线程对ConcurrentHashMap的value修改后,其他线程会立马可见。

总结

通过上面的列子,我们大概知道了使用可变的类可能会引发的线程安全问题,那么我们来总结一下实现不可变类的一些思路:

如何将一个类改造成不可变的类?

  1. 使用final关键字修饰所有成员变量,避免被修改
  2. 使用private修饰所有成员变量,可以防止子类及其他地方通过引用直接修改变量值
  3. 禁止提供修改内部状态的公开接口(比如set方法)
  4. 禁止不可变类被外部继承,防止子类改变其定义的方法的行为
  5. 如果类中存在数组或集合,在提供给外部访问之前需要做防御性复制

前面4点比较好理解,第5点需要另外说明一下

因为需要提供给外面的调用者调用所有ChinaConstructionBankDirectAccount中的set方法,为了方便起见,将所有以set开头的方法保存在METHOD_LIST中,虽然METHOD_LIST被final修饰,表示了METHOD_LIST指向的ArrayList对象不会变,但是ArrayList中的数据是可以发生变化的,为了保证METHOD_LIST中的元素也是不可变的,我们需要对MEHTOD_LIST做防御性复制(使用Collections.unmodifiableList方法将METHOD_LIST中的元素也不可以改变),这样可以保证外部无法修改我们的集合。

@Getter
@ToString
@EqualsAndHashCode(callSuper = true)
public class ChinaConstructionBankDirectAccount extends BaseBO {

    public static final List<Method> METHOD_LIST = Collections.unmodifiableList(
            Stream.of(ChinaConstructionBankDirectAccount.class.getDeclaredMethods())
                    .filter(method -> method.getName().startsWith("set"))
                    .peek(method -> method.setAccessible(true))
                    .collect(Collectors.toList())
    );
    
    
    public void setXXX(String xxx) {
        ...
    }
    ...
    ...
}

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值