加餐:“私有构造函数捕获模式”是怎么被设计出来的?

加餐:“私有构造函数捕获模式”是怎么被设计出来的?

《Java并发编程实战》4.3.4章,关于程序清单4-11有一个有趣的注释:

如果将拷贝构造函数实现为this(p.x,p.y),那么会产生竞态条件,而私有构造函数则可以避免这种竞态条件。这是私有构造函数捕获模式(Private Constructor Capture Idiom,Bloch and Gafter, 2005)的一个实例

百度上除了抄书,基本没什么有用的信息。搜了搜英文名,果然Stack Overflow上面有非常详细的讨论,强烈推荐阅读:链接

详细的阅读了每条post和评论后,下面我会用自己的理解来解释:“私有构造函数捕获模式”是怎么被设计出来的。
首先我们来看看为什么要用这个东西。最开始我们有这样一个类:

public class SafePoint {
    private int x,y;

    public SafePoint(int x, int y) {
        this.x = x;
        this.y = y;
    }

    public synchronized int[] get() {
        return new int[] {x, y};
    }

    public synchronized void set(int x, int y) {
        this.x = x;
        this.y = y;
    }
}

SafePoint这个类的状态由x和y构成,因此我们同时读写xy即可。到这里为止其实是没有问题的。当我们想要提供一个拷贝构造器(copy constructor)时,问题就来了。

首先从写代码的角度分析这个问题:

拷贝构造器的签名我们写成这样:

public SafePoint(SafePoint p)

因为要保证线程安全,我们在拷贝p时必须原子地读取p.xp.y

那我们加个synchronized吧:

public synchronized SafePoint(SafePoint p) {
    this.x = p.x;
    this.y = p.y;
}

报错了:modifier synchronized not allowed here(synchronzied不能在这里用)!想想看synchronzied方法的原理是在this上同步。调用构造器的时候this都还没被创建完成呢,所以synchronized不能加在构造器上是非常合理的!

哎,我们正好有这么一个方法p.get()。那我们是不是能这么写:

public SafePoint(SafePoint p) {
    int[] values = p.get();
    this(values[0], values[1]);
}

又报错了:call to this must be first statement in constructor(如果构造器中出现对this的调用,它必须是第一个调用)!同理,我们也不能使用Lock接口加锁,因为这样this就不是第一个调用了。那我们就只能这么写:

public SafePoint(SafePoint p) {
    this(...);
    //...do something else
}

那就把p.get()作为参数传给this吧!我们剩下能做的事就很明白了:p.get()返回了一个数组。所以我们还需要写一个参数为数组的构造器。

private SafePoint(int a[]) {
    this(a[0], a[1]);
}

为什么要声明为private呢?因为我们并不想让别人能够通过一个数组建造出来,我们这里没有为数组做任何的长度和数值检查,如果传入一个长度为1的数组,这里直接就会出错了。同时,我们也没有必要为这个数组做检查,因为这个构造器被创造出来仅仅是用来接收p.get()的返回值的。这个构造器就相当于一个helper构造器,因此跟helper函数一样,应该声明为private。

好了,下一个问题!这个模式不是要解决竞态条件吗。那么竞态条件会在哪里出现呢?

我们先看看错误示范。

public class SafePoint {
    private int x,y;

    public SafePoint(int a[]) {
        this(a[0], a[1]);
    }
    public SafePoint(SafePoint p) { //拷贝构造器
        this(p.x, p.y);//错误示范
    }
    public SafePoint(int x, int y) {
        this.x = x;
        this.y = y;
    }

    public synchronized int[] get() {
        return new int[] {x, y};
    }

    public synchronized void set(int x, int y) {
        this.x = x;
        this.y = y;
    }
}

首先,我们对这个类进行一个分析。SafePoint的状态由x和y两个变量决定。他们在哪里会被读写呢?三个地方:getter,setter,和错误示范的拷贝构造器(访问了另一个SafePoint实例x和y)。他们在哪里被锁保护了呢?咦,只有getter和setter!在拷贝构造器中,我们读取了p.x和p.y。。。诶?这俩不是私有变量吗,怎么能用.x.y的方式去访问啊?

这里再打个岔,私有和公有是对而不是对对象。也就是说私有变量是一个的私有变量,所以我们这里的拷贝构造器访问同一个类的对象的私有变量是没问题的。

咳咳,回归正题。我们在拷贝构造器中,读取了p.x和p.y,但并没有使用synchronzied。我们回忆一下synchronized的同步机制:线程在执行加了synchronzied的代码块时,会先申请synchronzied指定的锁,而获得锁的线程才能够执行代码块,其他的线程需要等待。注意了,synchronized的机制并不能同步这种情况:线程A执行的代码块加了synchronized,线程B执行的没加(也就是错误示范的这种情况)。那线程B就不会去和A竞争锁。假如我们有一个SafePoint{x=1, y=1}对象,如果线程A执行的是set方法:set(2,2),线程B执行的是拷贝构造器,就可能出现以下操作:

  1. 线程A将x改为2,这时y还是1
  2. 线程B读取x,得到2
  3. 线程B读取y,得到1 (这里就出现了问题)
  4. 线程A将y改为2

因为我们无法控制线程调度,线程可能在任何时候被打断,所以这种情况是完全可能出现的。

想模拟这种情况也很简单,我们可以在set方法写入x和写入y之间调用Thread.sleep()。与wait()不同,这个方法不会释放锁,不会出现“线程A其实是因为在等待的时候释放了锁,才让线程B读取到错误值”的误解。

public synchronized void set(int x, int y) {
    this.x = x;
    try {
        //写入x和写入y之间等一秒,保证执行拷贝构造器的线程先于"this.y = y"执行。
        Thread.sleep(1000);
    } catch (InterruptedException e) {
        e.printStackTrace();
    }
    this.y = y;
}

如果我们想用一个成员方法代替拷贝构造器,也是没有问题的:

public SafePoint copySafePoint(SafePoint originalSafePoint){
     int [] a = originalSafePoint.get();
     return new SafePoint(xy[0], xy[1]);    
}

下面还有一些拷贝的其他实现方法:

如果我们想用一个方法代替拷贝构造器,是完全可以的:

public SafePoint copySafePoint(SafePoint p){
	int[] values = p.get();
    return new SafePoint(values[0], values[1]);
}

只不过,我们可能因为某些原因必须要使用SafePoint的拷贝构造器(比如这么写可以让SafePoint用起来简洁,让我自我感觉良好)。

我们其实可以不使用构造器(不调用this),直接在拷贝构造器内同步被拷贝的对象并对x和y赋值。

public SafePoint(SafePoint p) {
    synchronized (p) { 
        this.x = p.x;
        this.y = p.y; 
    }
}

//如果我们的getter比较复杂,这里我们可能会想要重用它
public SafePoint(SafePoint p) {
    synchronized (p) { 
		int[] values = p.get();
        this.x = values[0];
        this.y = values[1]; 
    }
}

对于参数少的类,上面的方法可能更加简单。可是如果我们的类有很多的参数,或者构造逻辑很复杂,我们就不会再重写这些逻辑,而是想要重用已有构造器了。这时候就是这个名字超长的模式派上用场的时候了。

贴一下最后的代码:

public class SafePoint {
    private int x, y;

    private SafePoint(int a[]) {
        this(a[0], a[1]);
    }

    public SafePoint(SafePoint p) {
        this(p.get());//书上的写法
    }

    public SafePoint(int x, int y) {
        this.x = x;
        this.y = y;
    }

    public synchronized int[] get() {
        return new int[] {x, y};
    }

    public synchronized void set(int x, int y) {
        this.x = x;
        this.y = y;
    }

    public static void main(String[] args) {
        final SafePoint sp = new SafePoint(1,1);
        new Thread(() -> {
            sp.set(2, 2);
            int[] a = sp.get();
            System.out.println("sp: x="+a[0] + ", y=" + a[1]);
        }).start();

        new Thread(() -> {
            SafePoint copySp = new SafePoint(sp);
            int[] a = copySp.get();
            System.out.println("copySp: x="+a[0] + ", y=" + a[1]);
        }).start();
    }
}

码字不易,觉得有帮助就给我点个赞吧!我会继续努力的!

  • 19
    点赞
  • 10
    收藏
    觉得还不错? 一键收藏
  • 2
    评论
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值