并发编程实践笔记(2)-对象的共享

本文首发于: 我的个人博客: JoeMendez’s Blog
欢迎大家一起交流

2. 对象的共享

我们知道同步代码块和同步方法可以保证原子性,但是还有重要的另一方面:内存可见性(Memory Visibility), 确保当一个线程修改了对象状态后, 其他线程可以看到发生的状态变化,(即可以防止脏读)

1. 可见性

public class NoVisibility {
    private static boolean ready;
    private static int number;

    private static class ReaderThread extends Thread {
        @Override
        public void run() {
            while (!ready) {
                Thread.yield();
            }
            System.out.println(number);
        }
    }

    public static void main(String[] args) {
        new ReaderThread().start();
        number = 42;
        ready = true;
    }
}

上述程序中, 可能会持续循环下去, 因为可能看不到ready的值. 还有可能输出0, 即读线程看到了ready的值, 但是却没有看到之后写入number的值, 即"重排序(Reordering)",如果有重排序情况, 那么就无法确定程序按照指定的顺序执行.

出现的问题
  1. 失效数据
    • 类似脏读, 读取到已经失效的值, 即别的线程已经更新了该值,却还是读取到之前的值
  2. 非原子的64位操作
    • 当线程在没有同步的情况下, 可能会读取到失效值, 但是这个值至少是之前某个线程设置的值, 而不是一个随机值.这种安全性称为最低安全性(out-of-thin-airsafety)
    • 最低安全行适用于绝大多数变量, 但是存在例外: 非volatile的64位数值变量(double,long). JVM允许将64位的读/写操作分解为2个32位的操作, 如果对改变量的读写分别在不同的线程种, 可能会读取到某个值的高32位和另一个值的低32位, 这就导致严重的错误
    • 因此在多线程中使用共享且可变的long/double类型的变量是不安全的, 要用volatile声明或锁保护.
  • 加锁的意义:
    1. 保证原子性
    2. 保证内存可见性

2. 发布与逸出

  • 发布(Publish): 使对象能够在当前作用域之外的代码中使用.
    也就是在其他域中持有了对象的引用, 例如, 将对象的引用保存到公有的静态变量中, 以便任何类和线程都可以看到该对象

  • 逸出(Escape): 不该被发布的对象被发布时, 就称作逸出

    • 当某个对象逸出后, 必须假设有某个类或线程可能会无用该对象, 这正是使用封装的最主要原因: 封装使得对程序的正确性进行分析变的可能, 并使得无意中破坏设计约束条件变得更难
  • 当发布一个对象时, 在该对象的非私有域中引用的所有对象同样会被发布. 一般来说, 如果一个已经发布的对象能够通过非私有的变量引用和方法调用到达其他的对象, 那么这些对象也都会被发布

    • 假定有一个类C, 对C来说, "外部(Alien)方法"是指并不完全由C来规定的方法, 包括其它类中定义的方法以及C中可以被改写的方法(即不是私有方法(private)也不是终结方法(final)), 当把一个对象传递给某个外部方法时, 就相当于发布了这个对象

    也就是说, Javabean的属性都属于发布, 但是这是我们需要发布的, 而如果是不应该被发布的对象却被发布了, 就是逸出

    // 1. 将对象的引用保存到公有的静态变量中
    // knownSecrets 被发布, 间接的发布了Secret对象
    public static Set<Secret> knownSecrets;
    public void initialize(){
        knownSecrets = new HashSet<>();
    }
    
    // 2. 从非私有方法中返回一个引用, 同样会发布返回的对象
    class UnsafeStates{
        private String[] states = new String[] {
            "AK","AL"...
        };
        public String[] getStates(){
            return states;
        }
    }
    
    // 3. 发布一个内部的类实例
    public class ThisEscape {
    
        /**
        * 公有的构造器, 在对象没有构造成功时, 就发布了this 对象的引用
        */
        public ThisEscape(EventSource source) {
            source.registerListener(new EventListener() {
                @Override
                public void onEvent(Event e) {
                    doSomething(e);
                }
            });
            //  上面的相当于: source.registerListener(this::doSomething);
        }
    
        void doSomething(Event e) {
        }
    
        interface EventSource {
            void registerListener(EventListener e);
        }
    
        interface EventListener {
            void onEvent(Event e);
        }
    
        interface Event {
        }
    }
    
  • 安全的对象构造过程

    • 不要再构造过程中使this引用逸出

      • 在构造过程中使this逸出的常见错误:
        • 在构造函数中启动一个线程
        • 在构造函数中调用一个可改写的外部方法
    • 想要避免不正确的构造过程, 可以使用私有的构造函数和公有的工厂方法

      public class SafeListener {
          private final EventListener listener;
      
          private SafeListener() {
              System.out.println("私有构造方法.....");
              listener = this::doSomething;
          }
      
          /**
          * 工厂方法,
          */
          public static SafeListener newInstance(EventSource source) {
              System.out.println("工厂方法");
              SafeListener safe = new SafeListener();
              source.registerListener(safe.listener);
              return safe;
          }
          void doSomething(Event e) { }
      
          interface EventSource {
              void registerListener(EventListener e);
          }
      
          interface EventListener {
              void onEvent(Event e);
          }
      
          interface Event { }
      }
      

3. 线程封闭

仅在单线程内访问数据, 就不需要使用同步, 这种技术称为线程封闭(Thread Confinement), 是实现线程安全性的最简单方式之一.

常见应用有: JDBC的connection对象.将每个Connection对象封闭在一个线程中.

  1. Ad-hoc: 维护线程封闭性的职责完全地由程序实现承担. 这种方式很脆弱,建议使用更强的栈封闭或ThreadLocal

  2. 栈封闭: 线程封闭的一种特例, 只能通过局部变量才能访问对象.局部变量的固有属性就是封闭在执行线程中

    public int loadTheArk(Collection<Animal> candidates) {
        SortedSet<Animal> animals;
        int numPairs = 0;
        Animal candidate = null;
    
        // animals confined to method, don't let them escape!
        animals = new TreeSet<>(new SpeciesGenderComparator());
        animals.addAll(candidates);
        for (Animal a : animals) {
            if (candidate == null || !candidate.isPotentialMate(a)) {
                candidate = a;
            } else {
                ark.load(new AnimalPair(candidate, a));
                ++numPairs;
                candidate = null;
            }
        }
        return numPairs;
    }
    
  3. ThreadLocal

  • ThreadLocal理解 原理涉及到 countDownLatchconcurrentMap方面的知识, 后面再看, 先简单理解用法

    public class ConnectionDispenser {
        static String DB_URL = "jdbc:mysql://localhost:3306/mydatabase";
    
        private ThreadLocal<Connection> connectionHolder
                = ThreadLocal.withInitial(() -> {
                    try {
                        return DriverManager.getConnection(DB_URL);
                    } catch (SQLException e) {
                        throw new RuntimeException("Unable to acquire Connection, e");
                    }
                });
    
        public Connection getConnection() {
            return connectionHolder.get();
        }
    }
    

4. 不变性

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

  • 不可变性不等于将对象中的所有域都声明为final类型, 即使对象中所有的域都是final类型的, 这个对象也仍然是可变的, 因为在final类型的域中可以保存对可变对象的引用

  • 不可变对象的定义

    • 对象创建后其状态就不能修改
    • 对象的所有域都是final类型
    • 对象是正确创建的(在对象的创建期间, this引用没有逸出)
  • 不可变对象内部仍可以使用可变对象来管理他们的状态, 如下所示

    // 在可变对象基础上构建的不可变类
    @Immutable
    public final class ThreeStooges {
        private final Set<String> stooges = new HashSet<String>();
    
        public ThreeStooges() {
            stooges.add("Moe");
            stooges.add("Larry");
            stooges.add("Curly");
        }
    
        public boolean isStooge(String name) {
            return stooges.contains(name);
        }
    }
    
  • 尽管Set对象是可变的, 但是在Set对象构造完成后无法对其进行修改, stooges是一个final类型的引用变量, 因此所有的对象状态都通过一个final域来访问, 且构造函数保证了防止其他代码的访问

Final域

final类型的域是不可修改的, 但如果final域所引用的对象是可变的, 那么被引用的对象是可以被修改的. 在Java内存模型中, final域有特殊的语义, final域确保初始化过程的安全性, 从而可以不受限制地访问不可变对象, 并在共享这些对象时无需同步

使用volatile类型发布不可变对象
  • 对于访问和更新多个相关变量时出现竞争条件问题, 可以通过将这些变量全部保存在一个不可变对象中来消除, 如果是一个可变的对象, 那么就需要使用锁来确保原子性. 如果是一个不可变对象, 那么当线程获得了该对象的引用后, 就不必担心另一个线程会修改对象的状态. 如果要更新这些变量, 呢么可以创建一个新的容器对象, 但其他使用原有对象的线程仍然会看到对象处于一致的状态.

    • 当一个线程将volatile类型的cache设置为引用一个新的OneValueCache时, 其他线程会立即看到新缓存的数据

      // 使用指向不可变容器对象的volatile类型引用以缓存最新的结果
      @ThreadSafe
      public class VolatileCachedFactorizer extends GenericServlet implements Servlet {
          private volatile OneValueCache cache = new OneValueCache(null, null);
      
          @Override
          public void service(ServletRequest req, ServletResponse resp) {
              BigInteger i = extractFromRequest(req);
              BigInteger[] factors = cache.getFactors(i);
              if (factors == null) {
                  factors = factor(i);
                  cache = new OneValueCache(i, factors);
              }
              encodeIntoResponse(resp, factors);
          }
      }
      
      @Immutable
      public class OneValueCache {
          private final BigInteger lastNumber;
          private final BigInteger[] lastFactors;
      
          public OneValueCache(BigInteger i,
                              BigInteger[] factors) {
              lastNumber = i;
              lastFactors = Arrays.copyOf(factors, factors.length);
          }
      
          public BigInteger[] getFactors(BigInteger i) {
              if (lastNumber == null || !lastNumber.equals(i))
                  return null;
              else
                  return Arrays.copyOf(lastFactors, lastFactors.length);
          }
      }
      
    • cache相关的操作不会相互干扰, 因为OneValueCache是不可变的, 并且在每条相应的代码路径中只会访问它一次, 通过使用包含多个状态变量的容器对象来维持不变性,并使用volatile类型的引用确保可见性, 使得VolatileCachedFactorizer在没有显示的使用锁的情况下仍是线程安全的

5. 安全发布

  • 由于存在可见性问题, 其他线程看到的Holder对象将处于不一致的状态, 即便在该对象的构造函数中正确的构建了不变性条件, 这种不正确的发布将导致其他线程看到尚未创建完成的对象

    // 不安全的发布
    public Holder holder;
    public void initialize(){
        holder = new Holder(42);
    }
    
  1. 不正确的发布: 正确的对象被破坏

    • 不能指望一个尚未被完全创建的对象拥有完整性.

      public class Holder{
          private int n;
          public Holder(int n){
              this.n = n;
          }
      
          public void assertSanity(){
              if(n!=n){
                  throw new AssertionError("This statement is false!")
              }
          }
      }
      
      • 除了发布对象的线程外, 其他线程可以看到的Holder是一个失效值, 因此将看到一个空引用或之前的旧值
  2. 要安全的发布一个对象, 对象的引用以及对象的状态必须同时对其他线程可见, 一个正确构造的对象可以通过以下方式来安全地发布;

    • 在静态初始化函数中初始化一个对象引用
    • 将对象的引用保存到volatile类型的域或AtomicReferance对象中
    • 将对象的引用保存到某个正确构造对象的final类型域中
    • 将对象的引用保存到一个由锁保护的域中
  • 通常, 要发布一个静态构造的对象, 最简单和最安全的方式是使用静态的初始化器, 因为静态初始化器由JVM在类的初始化阶段执行

  • 对象的发布需求取决于它的可变性:

    • 不可变对象可以通过任意机制发布
    • 事实不可变对象必须通过安全方式来发布
    • 可变对象必须通过安全方式来发布, 并且必须是线程安全的或者由某个锁保护起来

小结

在并发程序中使用和共享对象时, 可以使用一些使用的策略, 包括:

  • 线程封闭: 线程封闭的对象只能由一个线程拥有, 对象被封闭在该线程中, 并且只能由这个线程修改
  • 只读共享: 在没有额外同步的情况下, 共享的只读对象可以由多个线程并发访问,但是任何线程都不能修改它. 共享的只读对象包括不可变对象和事实不可变对象
  • 线程安全共享: 线程安全的对象在其内部实现同步, 因此多个线程可以通过公有接口进行访问, 而不用进一步的同步
  • 保护对象: 被保护的对象只能通过持有特定的锁来访问, 保护对象包括封装再其他线程安全对象中的对象, 以及已发布的并由某个特定所保护的对象

参考资料:

Java Concurrency in Practice(Java并发编程实践) [作者: Brian Goetz]

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值