Java并发编程-对象共享

本文将介绍Java并发编程中的对象共享问题和解决办法。

一、对象可见性

可见性是一种复杂的属性,因为可见性中的错误总是违背我们的直觉。

为了确保多个线程之间对内存写入操作的可见性,必须使用同步机制

在下面的清单中 NoVisibility 说明了当多个线程在没有同步的情况下共享数据出现的错误。

程序中,主线程启动读线程,然后将 number 设为 42,并将 ready 设为 true。读线程一直循环直到发现 ready 的值变为 true,然后输出 number 的值。虽然看起来会输出 42,但事实上可能输出 0,或者根本无法终止。这是因为代码中没有使用足够的同步机制,因此无法保证主线程写入的ready 值和 nunber 值对于读线程来说是可见的。

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

    public static void main(String[] args) {
        new ReaderThread().start();
        number = 42;
        ready = true;
    }
    private static class ReaderThread extends Thread {
        public void run() {
            while (!ready) {
                Thread.yield();
            }
            System.out.println(number);
        }
    }
}

NoVisibility 可能会持续循环下去,因为读线程可能永远都看不到 ready 值。一种更奇怪的现象是,NoVisibility 可能会输出 0,因为读线程可能看到了写入 ready 值,但却没有看到之前写入 number 值,这种现象称为“重排序(Reordering)”。(注释:这看上去似乎是一种失败的设计,但却是使 JVM 充分地利用现代多核处理器的强大性能。)

在没有同步的情况下,编译器、处理器以及运行时等都可能对操作的执行顺序进行一些意想不到的调整。在缺乏足够同步的多线程程序中,要想对内存操作的执行顺序进行判断,几乎无法得出正确的结论。   

怎样能保证对象可见性?
1、加锁,适合所有场合
2、使用volatile,当然只适用于某些场合,在稍后章节会详细讲解。

二、误读失效数据

NoVisibility 展示了在缺乏同步的程序中可能产生错误结果的一种情况:失效数据。当读线程查看 ready 变量时,可能会得到一个已经失效的值。除非在每次访问变量时都使用同步,否则很可能获得该变量的一个失效值。

更糟糕的是,失效值可能不会同时出现:一个程序可能获得某个变量的最新值,而获得另一个变量的失效值。

失效数据还可能导致一些令人困惑的故障,例如意料之外的异常、被破坏的数据结构、不精确的计算以及无限循环等。

在如下程序清单 Mutableinteger 不是线程安全的,因为 get 和 set 都是没有同步的情况下访问 value 的。如果某个线程调用了 set,那么另一个在调用的get 线程可能会看到更新后的值,也可能看不到。

public class MutableInteger {
    private int value;
    public int get() {
        return value;
    }
    public void set(int value) {
        this.value = value;
    }
}
在程序清单 SynchronizedInteger 中,通过对 get 和 set 方法进行同步,可以使MutableInteger 成为一个线程安全的类。仅对 set 方法进行同步时不够的,调用 get 线程仍然会看到失效值。

public class SynchronizedInteger  {
    private int value;
    public synchronized int get() {
        return value;
    }
    public synchronized void set(int value) {
        this.value = value;
    }
}

由此可见,加锁能解决误读失效数据的问题。

画外音,synchronized三种使用方式:
1、synchronized使用在静态方法上,会对该类下所有的对象进行加锁。
2、synchronized使用在非静态方法上,会对该类每个对象分别进行加锁。
3、synchronized使用在代码块上,可以对指定对象进行局部加锁。这里又分为静态代码块,和非静态代码块,效果与使用在方法上相同。

三、非原子的64位操作

JVM起初设计的时候64位计算并不是普遍的,大部分机器还是32位的。在32位机器上计算long类型时,其实是分成高位和低位分别计算,在把结果返回。

但是jvm规范并没有强制要求这个操作时原子性的,所以在并发场景下,一个线程读到的long可能是另一个线程只计算了高位或低位的结果。为了避免这样的操作,需要把这个变量声明称volatile。

以int为例,int count=0 就是一个原子操作。假设count的当前值是0,另外一个线程设置count=100,这时获取count的值也许还是0。这里就不得不说下java中的主内存与工作内存:
在这里插入图片描述
对采用volatile关键字修饰变量的含义为:告诉jvm该变量直接操作主内存,而不是copy一份拷贝到工作内存,这时每个线程里看到值都是主内存中的最新值。

四、volatile变量

加锁机制既可以确保可见性有可以确保原子性,而volatile变量只能确保可见性。

volatile确保对一个变量的更新以可预见的方式告知其它的线程。当一个域声明为volatile类型后,编译器与运行时会监视这个变量,它是共享的,而且对它的操作不会与其它的内存操作一起被重排序。volatile变量不会缓存在寄存器或者缓存在对其它处理器隐藏的地方。
  volatile变量的操作不会加锁,也不会引起执行线程的阻塞,这使得volatile变量相对于sychronized而言是一种轻量级的同步机制。
  volatile变量通常被当做是标识完成、中断、状态的标记使用。它也存在一些限制。 只有满足下面所有的标准后,你才能使用volatile变量:

1、写入变量时并不依赖变量的当前值,或者能够确保只有单一的线程修改变量的值
2、变量不需要与其它的状态量共同参与不变约束
3、访问变量时,没有其他的原因需要加锁
volatile boolean asleep;
    ...
    while (!asleep)
      countSomeSheep();

五、发布对象与逸出

发布: 使对象能够在当前作用域之外的代码中使用。
逸出:某个不应该被发布的对象被发布。

发布的方式:
1、将一个指向该对象的引用保存到其他代码可以访问的地方, 也就是将对象的引用保存到一个公有的静态变量中,以便任何类和线程都能看见该对象。

public static Set<Secret> knownSecrets;
public void initialize() {
      knownSecrets = new HashSet<Secret>();
}

当发布某个对象时,可能会间接地发布其他对象。当发布一个对象时,在该对象的非私有域中引用的所有对象同样会被发布。一般来说,如果一个已经发布的对象能够通过非私有的变量引用或方法调用到达其他的对象,那么这些对象也都会被发布。
2、在一个公共方法内直接return 对象的引用

// 使内部可变状态逸出(不要这样做)
class UnsafeStates {
   private String[] states = new String[] {"AK", "AL" };
   public String[] getStates() { return states; }
   }

// GOOD
class UnsafeStates {
    private String[] states = new String[]{"AK", "AL"};
    public String[] getStates() {
        // 返回副本,这样就不会影响内部
        String[] tmp = new String[states.length];
        System.arraycopy(states, 0, tmp, 0, states.length);
        return tmp;
    }
}

如果按照上面方法来发布states,就会出现问题,因为任何调用者都能修改这个数组的内容。数组states已经逸出了它所在的作用域,因为这个本应是私有的变量已经被发布了。
3、发布一个内部的类实例。
当ThisEscape发布内部类EvnetLister时,也隐含地发布了ThisEscape实例本身,因为在这个内部类的实例中也包含了对ThisEscape实例的隐含引用。 ThisEscape尝试在构造函数中注册一个事件监听器。

// 隐式地使this引用逸出
public class ThisEscape {
    public ThisEscape(EventSource source) {
        source.registerListener(new EventListener() { //内部类
            public void onEvent(Event e) {
                doSomething(e);
            }
        });
    }
    void doSomething(Event e) {
    }
    interface EventSource {
        void registerListener(EventListener e);
    }
    interface EventListener {
        void onEvent(Event e);
    }
    interface Event {
    }
}

上面的例子隐式的提前暴露了this对象(在对象构造完成之前,或者说在构造方法中暴露了当前对象的引用)。在构造方法完成之前,当前对象的处于不可预测和不一致的状态,this对象提前暴露,超出了它的所用范围。正确的方法是可以把对象引用保存到变量中,等构造方法完成后在调用,如下;

// GOOD
    public class SafeListener {
        private final EventListener listener;
        private SafeListener() {
            listener = new EventListener() {
                public void onEvent(Event e) {
                    doSomething(e);
                }
            };
        }
        public static SafeListener newInstance(EventSource source) {
            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 {
    }
}

启示:不要在构造过程中使this引用逸出。如在构造函数中启动一个线程或者在构造函数中调用一个可改写的实例方法(既非private方法,也非final方法)时,都会导致this引用在构造过程中溢出。

如果想在构造函数中注册一个事件监听器或启动线程,那么可以使用一个私有的构造函数和一个公共的工厂方法来实现。

六、线程封闭

不共享数据,仅在单线程内访问数据,就不需要同步,这种技术被称为线程封闭。
线程封闭是实现线程安全性的最简单方式之一。

常见应用:JDBC的Connection对象。线程从连接池中获得一个 Connection 对象,并且用该对象来处理请求,使用完后再将对象返还给连接池。由于大多数请求都是由单个线程采用同步的方式来处理,并且在 Connection 对象返回之前,连接池不会再将它分配给其他线程,因此,这种连接管理模式在处理请求时隐含地将 Connection 对象封闭在线程中。

Java语言及其核心库提供了一些机制来帮助维持线程封闭性,例如局部变量和ThreadLocal类。

Ad-hoc线程封闭

Ad-hoc线程封闭是指,维护线程封闭性的职责完全由程序实现来承担。
Ad-hoc线程封闭是非常脆弱的,因为没有任何一种语言特性,能将对象封闭到目标线程上。
当决定使用线程封闭技术时,通常是因为要将某个特定的子系统实现为一个单线程子系统。
在volatile变量上存在一种特殊的线程封闭。当能确保只有单个线程对共享的volatile变量执行写入操作,那么就可以安全地在这些共享的volatile变量上执行“读取-修改-写入”操作。

栈封闭

在栈封闭中,只能通过局部变量才能访问对象。
栈封闭也被称为线程内部使用或者线程局部使用。
栈封闭比Ad-hoc封闭更易于维护,也更加健壮。
对于基本类型的局部变量,无论如何都不会破坏栈封闭性。
在维持对象引用的栈封闭性时,程序员需要多做一些工作以确保被引用的对象不会逸出。

//基本类型的局部变量与引用变量的线程封闭性
public int loadTheArk(Collection<Animal> candidates) {
        SortedSet<Aniaml> animals;
        int numPairs = 0; //基本类型的局部变量
        Aniaml candidate = null;
        // animals 被封闭在方法中,不要使它们逸出
        animals = new TreeSet<Animal>(new SpeciesGenderComparator());
        animals.addAll(candidates);
        for (Animal a : animals) {
            numPairs++;
        }
        return numPairs;
}

在loadTheArk中实例化一个TreeSet对象,并将指向对象的一个引用保存到animals中。此时,只有一个引用指向结合animals,这个引用被封装在局部变量中,因此也被封闭在执行线程中。(局部变量的固有属性之一就是封闭在执行线程中,它们位于执行线程的栈中,其他线程无法访问这个栈。)然而,如果发布了对集合animals(或者对该对象中的任何内部数据)的引用,那么封闭性将被破坏,导致对象animals的逸出。

ThreadLocal类

ThreadLocal提供了get与set等访问接口或方法,这些方法为每个使用该变量的线程都存有一份独立的副本,因此get总是返回由当前执行线程在调用set时设置的最新值。
ThreadLocal对象通常用于防止对可变的的单实例变量或全局变量进行共享。

当某个频繁执行的操作需要一个临时对象,而同时又希望避免在每次执行时都重新分配该临时对象,就可以使用该技术。

当某个线程初次调用ThreadLocal.get方法时,就会调用initialValue来获取初始值。ThreadLocal变量类似于全局变量,它能降低代码的可重用性,并在类之间引入隐含的耦合性。

以connectionHolder方法来讲解:在单线程应该程序中可能会维持一个全局的数据库连接,并在程序启动时初始化这个连接对象,从而避免在调用每个方法时都要传递一个Connection对象。由于JDBC的连接对象不一定是线程安全的,因此,当多线程应该用程序在没有协同的情况下使用全局变量时,就不是线程安全的。通过将JDBC的连接保存到ThreadLocal对象中,每个对象都会用于属于自己的连接。

//使用ThreadLocal来维持线程封闭性
private static ThreadLocal<Connection> connectionHolder  = new ThreadLocal<Connection>()
 {  //将JDBC的连接保存到ThreadLocal对象中
          @Override
          public Connection initialValue() { 
             return DriverManager.getConnection(DB_URL);
          }
  };
public static Connection getConnection() {
     return connectionHolder.get();
}

当某个频繁执行的操作需要一个临时对象,例如一个缓冲区,而同时又希望避免在每次执行时都重新分配该临时对象,都可以使用ThreadLocal来维持线程封闭性。

不变性

如果某个对象在被创建后其状态就不能被修改,那么这个对象就称为不可变对象,不可变对象只有一种状态,并且该状态由构造函数来控制。

不可变对象一定是线程安全的。但满足以下条件时,对象才是不可变的:
(1)对象创建以后其状态就不能修改;
(2)对象的所有域都是final类型;
(3)对象是正确创建的(在对象的的创建期间,this引用没有逸出)。
在不可变对象的内部仍可以使用可变对象来管理它们的状态,如下例:

//在可变对象基础上构建的不可变类
@Immutable
 public final class ThreeStooges {
    private final Set<String> stooges = new HashSet<String>();//被final修饰,stooges是一个final类型的引用变量,所有的对象状态都通过一个final域来访问
 
    public ThreeStooges() {
        stooges.add("Moe");
        stooges.add("Larry");
        stooges.add("Curly");
    }
 
    public boolean isStooge(String name) {
        return stooges.contains(name);
    }
}

关键字final用于构造不可变对象。final类型的域是不能修改的(但如果final域所引用的对象是可变的,那么这些被引用的对象是可以修改的)。final域能确保初始化过程的安全性,从而可以不受限制地访问不可变对象,并在共享这些对象的时候无需同步。

除非需要更高的可见性,否则应该将所有的域都声明为私有域,除非需要某个域是可变的,否则应该将其声明为final域,这是良好的编程习惯。

七、安全发布

1、不可变对象和初始化安全性
即使某个对象的引用对其他线程是可见的,也不意味着对象状态对于使用该对象的线程来说一定是可见的。为了确保对象状态能呈现出一致的视图,就必须使用同步。
Java内存模型为不可变对象的共享提供了一种特殊的初始化安全性保证。
任何线程都可以在不需要额外同步的情况下安全地访问不可变对象,即使在发布这些对象时没有使用同步。

2、安全发布的常用模式
要安全地发布一个对象,对象的引用以及对象的状态必须同时对其他线程可见。一个正确构造的对象可以通过以下方式来安全地发布:
· 在静态初始化函数中初始化一个对象引用。例如
public static Holder holder = new Holder(42);
(1) 将对象的引用保存到volatile类型的域或者AtomicReferance对象中。
(2) 将对象的引用保存到某个正确构造对象的final类型域中。
(3) 将对象的引用保存到一个由锁保护的域中。如线程安全容器就是通过这种方式。

线程安全库中的容器类提供了以下的发布安全保证:
· 通过将一个键或值放入Hashtable,synchronizedMap或者ConcurrentMap中,可以安全地将它发布给任何从这些容器中访问它的线程(无论是直接访问还是迭代器访问)
· 通过将某个元素放入Vector,CopyOnWriteArrayList,CopyOnWriteArraySet,synchronizedList或synchronizedSet中,可以将该元素安全地发布到任何从这些容器中访问该元素的线程
· 通过将某个元素放入BlockingQueue或ConcurrentLinkedQueue,可以将该元素安全地发布到任何从这些容器中访问该元素的线程

通常,要发布一个静态构造的对象,最简单和最安全的方式是使用静态的初始化器:
public static Holder holder = new Holder(42);

3、事实不可变对象
如果对象从技术上来看是可变的,但其状态在发布后不再改变,那么把这种对象称为事实不可变对象。
在没有额外的同步的情况下。任何线程都可以安全地使用被安全发布的事实不可变对象。

4、可变对象
要安全地共享可变对象,这些对象就必须被安全地发布,并且必须是线程安全的或者由某个锁保护起来。

对象的发布需求取决于它的可变性:
(1)不可变对象可以通过任意机制来发布。
(2)事实不可变对象必须通过安全方式来发布。
(3)可变对象必须通过安全方式来发布,并且必须是线程安全的或者由某个锁保护起来。

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

线程封闭。线程封闭的对象只能由一个线程拥有,对象被封闭在该线程中,并且只能由这个线程修改。

只读共享。在没有额外同步的情况下,共享的只读对象可以由多个线程并发访问,但任何线程都不能修改它。共享的只读对象包括不可变对象和事实不可变对象。

线程安全共享。线程安全的对象在其内部实现同步,因此多个线程可以通过对象的公有接口来进行访问而不需要进一步的同步。
保护对象。被保护的对象只能通过持有特定的锁来访问。保护对象包括封装在其他线程安全对象中的对象,以及已发布的并且由某个特定锁保护的对象。

总结

java并发访问共享数据的三种方式:
1、synchronized 对共享变量进行变更的方法、代码块 使用synchronized关键字(或者Lock)。
2、对共享变量使用volatile关键字。
3、使用Atomic包中的原子性操作类。

为了确保所有线程都能看到共享变量的最新值,所有执行读操作或者写操作的线程都必须在同一个锁上同步。

参考来源:
1、https://www.cnblogs.com/zhaww/p/9288634.html
2、https://www.jianshu.com/p/32e030112841
3、https://blog.csdn.net/weixin_41704428/article/details/80542944
4、
5、

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值