V2EX = way to explore
V2EX 是一个关于分享和探索的地方
现在注册
已注册用户请  登录
PonysDad
V2EX  ›  Java

关于 Builder 模式线程安全的疑问

  •  
  •   PonysDad · 2019-11-19 23:05:18 +08:00 · 4022 次点击
    这是一个创建于 1591 天前的主题,其中的信息可能已经有所发展或是发生改变。
    public class Address {
    
        private final Long id;
        private final String street;
    
        Address(Long id, String street) {
            this.id = id;
            this.street = street;
        }
    
        public static AddressBuilder builder() {
           return new AddressBuilder();
        }
    
        public static class AddressBuilder {
            private Long id;
            private String street;
    
            AddressBuilder() {
            }
    
            public AddressBuilder id(Long id) {
                this.id = id;
                return this;
            }
    
            public AddressBuilder street(String street) {
                this.street = street;
                return this;
            }
    
            public Address build() {
                return new Address(id, street);
            }
    
            public String toString() {
                return "Address.AddressBuilder(id=" + this.id + ", street=" + this.street + ")";
            }
        }
    }
    
    //测试类
    public class Test {
    
        Address obj;
        
        public void write() {
        	Address address = Address.builder()
                                .id(1L)
                                .street("street 1")
                                .build();
           	obj = address;
        }
        
        public void read() {
        	Long id = obj.id;
        }
    }
    

    现在,线程 A 调用 write 方法,创建 Address 实例并赋值给 obj,线程 B 调用 read 方法,读取 id 值。 这样子应该不是线程安全的吧,即使 id 有 final 修饰。

    现在疑惑是,使用 Builder 模式创建对象一定是线程安全的吗?

    虽然理论每次调用 build 方法会创建一个新实例,各线程之间不共享该实例也就不会出现并发问题。 但是,使用 Java Bean 也可以这样子

    Address address = new Address();
    address.setId(99L);
    address.setStreet("Street 2");
    

    每个线程都创建一个新实例啊,即使指令重排也没有影响啊。

    但是 Effective Java 中又强调使用 Builder 模式可以规避 Jave Bean 创建对象时,出现的线程不安全问题。

    10 条回复    2019-11-24 10:10:13 +08:00
    billlee
        1
    billlee  
       2019-11-20 00:26:23 +08:00   ❤️ 1
    并不能保证线程安全,要线程安全必须加 synchornized. 设计模式只能让代码变得更好看,减少编码上的人为错误。

    Immutable 对象可以减少人为的编码错误 -> 构造 immutable 对象可能需要很多参数的构造函数 -> 很多参数的函数不好看 -> 用 builder pattern 可以变得更好看

    仅此而已

    有 named parameters 语法的 kotlin 和 scala 就不需要 builder pattern 了。
    vjnjc
        2
    vjnjc  
       2019-11-20 00:29:25 +08:00
    用 Address address = new Address(id, "street", "val3")能保证线程安全,
    用 builder 也能保证。


    address.setId(99L);
    address.setStreet("Street 2");
    不能保证线程安全,举个例子 setId()这个 api 被多个线程访问,会互相覆盖,不能保证得到正确值。
    xzg
        3
    xzg  
       2019-11-20 09:45:33 +08:00
    首先你的两个线程是持有同一个 test 对象来分别调用 write 和 read 方法?如果是那肯定非线程安全,如果是持有 new 的两个 test 对象,那就没影响了
    PonysDad
        4
    PonysDad  
    OP
       2019-11-20 13:46:57 +08:00
    @billlee @vjnjc
    我感觉用 builder pattern 构造 immutable 对象也不是线程安全的。
    ```java
    Address address = Address.builder()
    .id(1L)
    .street("street 1")
    .build();
    ```
    可能编译后(指令重排)如下:
    ```java
    AddressBuilder addressBuilder = new AddressBuilder();
    Address address = addressBuilder.build();
    addressBuilder.id = 1L;
    addressBuilder.street = "street 1";
    ```
    这时候,线程 B 可能读取到 address 实例未初始化的值。

    但是如果使用构造函数实例化,final 内存模型能保证 address 已经初始化完毕。

    不知道我的理解是否有错?
    请不吝赐教。
    PonysDad
        5
    PonysDad  
    OP
       2019-11-20 13:52:10 +08:00
    obj = address;

    补上编译后代码漏了上面一行。
    PonysDad
        6
    PonysDad  
    OP
       2019-11-20 13:52:47 +08:00
    这一行是接在
    Address address = addressBuilder.build();
    后面
    billlee
        7
    billlee  
       2019-11-20 14:09:38 +08:00   ❤️ 1
    @PonysDad #4 build() 对 id, streat 有数据依赖,不会被重排或者乱序发射的。这里不是指令重排的问题,是内存可见性的问题,一个线程对内存的写操作不一定能被另一个线程看到。
    不需要考虑 builder pattern, 如果直接构造一个对象,把引用传递给另一个线程,不做线程同步,另一个线程可能看到的状态就是乱的。
    可以去看一下 java memory model, 或者计算机结构里面关于 cache coherence 的内容。
    vjnjc
        8
    vjnjc  
       2019-11-20 16:24:24 +08:00
    @PonysDad #4 你举的例子不成立。
    1 指令重排不会更改你的源码的顺序。
    2 访问到未初始化的 reference 只会发生在这段代码会被 2 个 thread 并发执行的时候,你举的例子没有表现出这个条件
    PonysDad
        9
    PonysDad  
    OP
       2019-11-20 21:48:23 +08:00
    @billlee
    一针见血。
    我漏看了 return new Address(id, street);是传递两个值过去的,且一直在纠结这个构造函数 final 域的问题。
    addressBuilder.id = 1L;
    addressBuilder.street = "street 1";
    只有这两句可以被重排。
    剩下的是内存可见性问题。
    PonysDad
        10
    PonysDad  
    OP
       2019-11-24 10:10:13 +08:00
    @billlee @vjnjc

    《 Effective Java 》中有一段这样的描述:
    -----------------------------------------------------------------------------------------------------------------------------
    不幸的是,JavaBeans 模式本身有严重的缺陷。由于构造方法在多次调用中被分割,所以在构造过程中 JavaBean
    可能处于不一致的状态。该类没有通过检查构造参数参数的有效性来执行一致性的选项。在不一致的状态下尝试使用
    对象可能会导致与包含 bug 的代码大相径庭的错误,因此很难调试。
    -----------------------------------------------------------------------------------------------------------------------------
    一直模拟出构造方法被割裂而导致的不一致。
    不知道大家有没有一个很好例子?
    关于   ·   帮助文档   ·   博客   ·   API   ·   FAQ   ·   我们的愿景   ·   实用小工具   ·   5875 人在线   最高记录 6543   ·     Select Language
    创意工作者们的社区
    World is powered by solitude
    VERSION: 3.9.8.5 · 34ms · UTC 02:23 · PVG 10:23 · LAX 19:23 · JFK 22:23
    Developed with CodeLauncher
    ♥ Do have faith in what you're doing.