Loading

Java并发——共享对象

本篇博文是Java并发编程实战的笔记。

并发编程面临两个大的问题,一个就是关于共享数据的读写访问该如何同步,还有一个就是如何安全的将一个对象共享出去(给多个线程使用)。

可见性

可见性是你在共享一个对象时要缜密考虑的问题,它是说一个线程对于一个对象状态的修改是否能够及时的被另一个线程察觉,如果不能就会发生一些莫名其妙的问题。

可见性问题源于CPU为获得更高的执行效率而做的一些优化带来的副作用,这些优化包括缓存和指令重排。

缓存

老生常谈的问题,一旦有两个组件之间需要通信并且还要保证高性能的时候就会用到缓存。

CPU和内存之间存在一些交互,CPU执行程序指令,内存保存程序和程序数据,所以它们之间时常要通信,而读取内存需要消耗掉CPU的很多个指令周期,在这一段时间,CPU本来能干很多事。所以缓存就来了,CPU会把内存中的常用数据搬到离CPU更近,速度更快的高速缓存或寄存器中,日后再次读取就不用读取内存了。

所以缓存就是:把经常需要用到的东西搬得离我近点儿,下次我用到就不用跑那么远了

Java中对上面的CPU和内存之间的缓存模型的抽象是工作内存和主内存,每一个线程有一个工作内存,这个工作内存通常就会是寄存器或者高速缓存,而主内存就是我们的物理内存。一个线程会把主内存中的数据搬到它的工作内存中使用,以获得更快的执行速度。

通过上面的模型,我们也看出工作内存是线程私有的,主内存是大家公有的,在这样的模型里多线程之间如果想要共同读写一份共享数据,如果不进行同步,各个线程的工作内存里就可能会有不一样的数据副本,这样可见性问题就产生了。

比如主内存中有个变量x,线程A写入它之后并未将它及时的写回到主内存,此时线程B读取x,它读到的就是x的旧值。

不过也不用对Java灰心,这是并发程序设计必须要面临的问题,Java也提供了很多规范和手段来解决这些问题。

如下的代码展示了缓存可能引发的可见性问题,在极端情况下,ready变量设置为true后,WorkThread可能永远不会发现,然后一直这么运行下去。

class A {
  private static boolean ready;

  static class WorkThread extends Thread {
    @Override
    public void run() {
      while(!ready) Thread.yield();
      // do something
    }
  }

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

一旦我们谈到并发编程,很多东西都是理论上可能发生的,也许它们在某些平台的JDK上可能根本不会发生。但尽管可能程序运行一百年也不会出现一次这个问题,我们也要了解这些可能性。

指令重排

指令重排是指CPU可能会对程序的字节码指令的先后顺序进行颠倒执行,而不是顺序执行,但指令重排必须保证在重排之后在单线程内部看起来程序还是和没重排过一样。

举个例子:

int a = 10;
int b = 20;
print(a);
print(b);

可能被重排成:

int a = 10;
print(a);
int b = 20;
print(b);

但是如下代码:

int a = 10;
int b = 3;
b = a * 10;
a = b + 2;

这里最后b=100a=102,如果按如下顺序重排:

int a = 10;
int b = 3;
a = b + 2;
b = a * 10;

最后的结果是a=5b=50,这样单线程的顺序性就得不到保证了,所以CPU不能进行上面这种重排序。

重排序出现的一个前提条件是要重排的一条指令的执行效果不依赖另一条指令的执行效果,上面对ab的第二次赋值操作,它们明显是互相依赖的,所以它们不能被重排序,重排序它们将得到错误的执行结果。对于多线程程序,指令重排并不保证结果的正确性,所以我们需要自己采用一些同步手段来保证正确。

CPU实际执行的都是它所支持的指令集中的机器指令,而非Java命令,我这样写只是为了便于理解。实际上,一条Java指令编译成Java字节码都可能需要好几条字节码指令,更别说最终由虚拟机实际执行或者由即时编译器编译出来的CPU机器指令了。

下面是重排序可能带来的一种并发错误:

class A {
  private static boolean ready;
  private static int number;

  static class WorkThread extends Thread {
    @Override
    public void run() {
      while(!ready) Thread.yield();
      // do something
      println(number);
    }
  }

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

在上面的例子中,WorkThread可能打印出number为0,因为main方法可能碰巧被重排成这样:

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

执行完ready=true后,碰巧WorkThread中发现了ready状态更改,结束循环并读取number,此时number是0,因为主方法中的赋值语句尚未执行。

失效数据

失效数据即数据已经被一个线程更新,但另一个线程读取到的仍然是旧数据,比如上面的ready

非原子的64位操作

Java提供并发共享数据的最低安全性,即保证线程可能得到失效数据,但是这个数据一定是之前某个线程所设置的

但是对于非volatilelongdouble,JVM允许它们不是原子的,它们也不受最低安全性保护。

大部分64位商用虚拟机(如Hotspot)都提供这两种类型的最低安全性保护

加锁与可见性

使用Java的内置锁(同步代码块)可以保证下一个进入同步代码块的线程可以看到上一个在同步代码块中的线程所做的全部操作。

volatile变量

volatile不是锁,它被声明在一个变量上,被声明的变量不会参与指令重排,不会被缓存在寄存器或者任何对其它处理器不可见的地方。

一些虚拟机可能的实现是在volatile变量操作的后面加上一个内存屏障来保证后面的代码不会重排到volatile操作前,并且立即将该变量的缓存写入主内存。

简单来说,volatile只保证可见性,一个线程对它更新后,另一个线程能立刻得到更新后的值,但它不提供任何其它的同步机制,比如下面的代码还是有问题:

volatile int counter;
public void incr() {
  counter++;
}

因为counter++不是一个原子操作,volatile的典型应用是标志,比如上面的ready标志。

发布与逸出

发布即把一个对象共享出去,使它能够在当前作用域之外的代码中使用。程序的组件间不可能没有交互,所以发布对象在所难免,程序员要做的就是通过封装和各种约束来保证发布出去的对象是安全的,不希望被改变的对象属性不会被轻易的改变。

需要注意的是,当你发布出去一个对象,所有该对象发布出去的对象也都会被发布,这是一个连带关系,同时可能会产生一些副作用,造成本不应该被发布的对象被发布了,或者是单纯的由于你的疏忽导致对象被错误的发布,这种情况就叫逸出

先说风险,一旦一个对象逸出,那么该对象可能随时被误用。有多大的风险取决于被逸出出去的对象。

下面是可能的几种对象被发布的手段:

  1. 直接通过公有域发布

    class C {
      public User user;
    
      public C() {
        user = new User(...);
      }
    }
    
  2. 通过方法返回

     class C {
       private User user;
       public User getUser() { return user; }
     }
    
  3. 通过将对象传递给外部方法
    外部方法就是行为不由本类决定的方法,包括其它类中的方法和本类中可以被改写的非private非final方法。

    class C {
       private User user;
       public void doSomething() {
         UserUtils.changeUser(user);
       }
    }
    
  4. 发布内部类实例
    这个比较绕,但是也不难理解

    class C {
      public C(EventSource source) {
        source.registerListener(
          new EventListener() {
            public void onEvent(Event e) {
              // `onEvent`方法里可以调用任何C类的方法和属性,但它却不受C类本身控制
              doSomething();
            }
          };
        )
      }
      private void doSomething() {
        // ...
      }
    }
    
  5. 连带发布
    当你发布一个对象,这个对象发布的所有对象都被连带的发布:

    public Set<User> users; // users中的每一个user都被发布
    public Session session; // session中发布的每一个对象都被发布
    

不安全的对象构造

上面的第4种发布手段是很危险的,并且是一定要避免的,即在构造方法中使this引用逸出

构造方法是对一个对象进行实例化的方法,该方法被执行完,对象才算真正的构建出来,如果你在构造方法中把this引用逸出了,那么别人拿到的就是不完整的对象,这样可能会出现很严重的问题。

线程封闭

最简单的解决并发问题的办法——不共享数据。

上面介绍了可见性可能引发的一些问题还有对象发布的一些手段和注意事项,好像我们马上就要开始着手学习如何安全的在并发环境下共享数据了,然后这书的作者告诉你,我先来教你如何“不共享数据”。。。

我不明白该书为啥把它安排在名字叫“共享对象”的一章中,但是...无伤大雅哈哈哈哈。

Ad-hoc线程封闭

不重要,略。

栈封闭

栈封闭就是线程别访问任何公有变量,只访问方法作用域中的局部变量。众所周知,栈空间是线程私有的,自然存在这里没有问题。

当然,你要花一些心思确保方法调用中不会出现某些引用逸出的情况,比如:

public boolean loadSomeData() {
  Set<Entry> datas = new HashSet();
  // ... dosomething ...
  datas = Utils.checkAndFilterData(datas);

  // ... dosomething ...
  return datas;
}

你并不知道Utils.checkAndFilterData中会不会新开一个线程对这个datas做些什么导致loadSomeData和另一个线程对data的并发读写。

ThreadLocal类

get方法返回调用者线程最后通过set方法向其中设置的数据。

不变性

哈,终于说到了和共享对象有关的内容了。

任何并发问题都来源于被不同线程共享的数据状态的改变,不管是由于原子性引发的问题还是可见性引发的问题。如果数据只有一种状态,那么自然永远不会发生问题。

不可变对象(Immutable Object)就是一种状态不可变的对象,一般情况下它在构造函数中初始化它的状态,初始化完成后它的状态就再也不改变。

你可能觉得上面说的都是废话,并没有解决实际问题,但其实,并发编程中的大部分共享对象都可以被设计为不可变的。不可变对象应该遵守如下规则:

  1. 对象创建以后其状态就不能被修改(包括该对象中引用的其他对象的状态也不能被修改)
  2. 对象的所有域都是final类型
  3. 对象是正确创建的(在对象的创建期间this引用没有逸出)

Final域

final除了不可变域之外还有一个语义:

当构造函数结束时,final类型的值是被保证其他线程访问该对象时,它们的值是可见的

这一点只需先记住,稍后会有一个小例子,到时候再回来查询这句话或许就豁然开朗了。

同时,final关键字也可以提醒程序员该域不希望被更改。

示例:使用volatile类型来发布不可变对象

不可变对象不意味着该对象不能被更新,通常使用替换对象的方式来更新一个不可变对象,而且不可变对象通常非常小。

@Immutable
public class Point {
    public final int x;
    public final int y;

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

public class Human {

    private volatile Point position = new Point(0, 0);

    public void move(Point newPosition) {
        position = newPosition;
    }

    public Point getPosition() {
        return position;
    }

}

现在多个线程可以共同操作这个Human对象,不可变对象Point保证了没有线程会读到不完整的或者是错误的数据,volatile保证了没有线程会读到过时的数据,即一有线程更新position,那么另一个线程立即会读取到新的位置。

安全发布

下面我们将讨论如何安全的发布一个对象。

正确的对象被破坏

public class Holder {
    private int n;
    private Holder(int n) {
        this.n = n;
    }

    public void assertSanity() {
        if (n != n) {
            throw new AssertionError("This statement is false.");
        }
    }
}

public class HolderTest {
    public Holder holder;

    public void initHolder() {
        holder = new Holder(new Random().nextInt());
    }
}

如上Holder对象如果被HolderTest发布,那么这次发布就是不安全的。

  1. 某线程可能由于可见性原因导致访问holder时访问到null
  2. 某线程可能由于可见性原因导致访问holder时访问到之前旧的holder
  3. 某线程看到的holder引用值是新的,但是其状态值是旧的(由于n不是final字段,JVM没有提供该属性再在构造方法结束后一定初始化的保证,所以可能读到n=0
  4. 某线程第一次读到失效状态(0),第二次读到新状态(Random().nextInt())

如果把上面的holder改成volatile,问题能得到解决吗?

第一个和第二个能解决,因为第一个第二个本质上是holder引用更新的不及时,一个线程对它更新另一个线程没有及时看到,volatile能解决这个问题。

第三个第四个不能解决,所以,即使某个对象的引用改变对其它线程是可见的,不代表该对象中的状态改变对其它线程也是可见的

问题3、4的根本原因是Holder不是不可变对象,且没有任何同步机制来保护n的初始化,所以通过这个例子也容易理解为什么之前说要求不可变对象的所有域都是final了,因为:

当构造函数结束时,final类型的值是被保证其他线程访问该对象时,它们的值是可见的

所以:

符合不可变对象规则的对象总是线程安全的,即使它们没被安全的发布

安全发布的常用模式

从上面的Holder的例子可以看出,一个可变对象如果想要安全的发布,它的引用以及状态必须同时对其它线程可见,否则就会发生上面那四种不一致问题。下面是常用的安全发布对象的模式:

  1. 在静态初始化函数中初始化对一个对象的引用(即static块或static变量直接赋值,能进入<clinit>方法的位置)
  2. 将对象的引用保存到volatile类型的域或者AutomicReference对象中
  3. 将对象的引用保存到某个正确构造对象的final类型域中
  4. 将对象的引用保存到某个由锁保护的域中(比如放到某些线程安全的容器中)

事实不可变对象

如果对象从技术上来看是可变的(不满足上面不可变对象的定义),但其状态在发布后不会再改变,我们称它为事实不可变对象

在没有额外同步的情况下,任何线程都可以安全的使用被安全发布的事实不可变对象

比如Date是可变的,如果你把它当不可变对象来用,并将它发布到synchronizedMap中,那么所有线程都可以安全的使用这个Date对象。

可变对象

如果对象在构造以后可以修改,那么安全发布只能保证发布当时状态的可见性。所以你不仅要保证它安全发布,在每次访问对象时同样需要使用同步机制来确保后续修改操作的可见性和正确性。

参考

posted @ 2022-04-03 15:33  yudoge  阅读(500)  评论(0编辑  收藏  举报