并发编程实战-对象的共享


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) {
        //无法保证主线程写入的number和ready对于读线程可见
        //有可能主线程执行完了,读线程才开始执行,这样读线程就会陷入死循环
        new ReaderThread().start();
        number = 42;
        ready = true;
    }
}

上面的例子还会出现一种情况–读线程输出0.这是因为读线程读到了ready的值但是没有读到写入的number的值(ready的写入操作跑到number之前了),造成这种现象的原因是"重排序".

1.1 失效数据

上面的例子中,如果执行顺序是 ready = true -> 读线程 -> number = 42 先执行了,读线程读取的就是失效数据,这时number为0

采用同步能避免获取失效数据,不仅写线程要进行同步操作,读线程同样也要进行同步操作.

1.2 非原子的64位操作

多线程没有同步时可能会获取到一个失效值,但至少这个值是之前某个线程设置的而不是随机值,这中安全性保证也被成为最低安全性.
绝大多数变量适用最低安全性.但对非volatile类型的64位数值变量(double和long)例外.
java内存模型要求,变量的读取操作和写入操作都必须是原子操作,但对于非volatile的long和double,JVM允许将64位的读操作或写操作分解为两个32位的操作.当都组读取一个非volatile类型的long变量时,如果对该变量的读操作和写操作在不同的线程中执行,那么很可能读取到某个值的高32位和另一个值得低32位.

在多线程程序中使用共享且可变的long和double等类型的变量也是不安全的,除非用关键字volatile来声明他们,或者用锁保护起来.

1.3 加锁与可见性

内置锁可以保证某个线程以一种可预测的方式来查看另一个线程的执行结果.
如果线程A和线程B获取了同一个锁,那么在解锁之前,线程A对于变量的操作对于线程B来说都是内存可见的.

加锁的含义不仅仅局限于互斥行为,还包括内存可见性.为了确保所有线程都能看到共享变量的最新值,所有执行读操作或写操作的线程都必须在同一个锁上同步.

1.4 Volatile 变量

当把变量声明位volatile类型后,编译器与运行时都会注意到这个变量是共享的,因此不会将该变量上的操作与其它内存操作一起重排序.
volatile变量不会被缓存到寄存器或者对其它处理器不可见的地方,因此在读取volatile变量时总会返回最新值.
volatile 变量的使用建议:

  • 不要过分依赖volatile变量来保证内存可见性.
  • 仅当volatile变量能简化代码的实现以及对同步策略的验证时,才应该使用他们.
  • 如果在验证正确性时需要对可见性进行复杂的判断,那么就不要使用volatile变量.

volatile变量的正确使用方式:

  • 确保它们自身状态的可见性.
  • 确保他们所引用对象的状态的可见性.
  • 标识一些重要的程序生命周期事件的发生(例如,初始化或关闭)

volatile变量通常用作某个操作完成,发生中断或者状态的标志.对于类似count++这样的操作,volatile并不能确保递增的原子性.

加锁机制既能确保可见性又能确保原子性,但volatile只能确保可见性

当且仅当满足以下所有条件时,采用该使用volatile变量:

  1. 对变量的写入操作不依赖变量的当前值,或者你能确保只有单个线程更新变量的值.
  2. 该变量不会与其它状态变量一起纳入不变性条件中.
  3. 在访问变量时不需要加锁.

2.发布与逸出

  • 发布:使对象能够在当前作用域之外的代码中使用.
  • 逸出:某个不应该发布的对象被发布时,这种情况就被称为逸出

发布对象最简单的方法是将对象的引用保存到一个公有的静态变量中,以便任何类和线程都能看见该对象.

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

如果发布了本应该私有的变量,就是逸出

	class UnsafeStates {
		private String[] states = new String[] {
			"AK","AL" ...
		};
		public String[] getStates() { return states; }
	}

像上面这样,数组states已经逸出了它所在的作用域,因为这个本应该私有的变量已经被发布了

当把一个对象传递给某个外部方法是,就相当于发布了这个对象.
无法预知被发布的对象会被如何使用
某个对象逸出后,必须假设有某个类或线程可能会误用该对象.

还有一种发布对象机制

	public class ThisEscape {
		public ThisEscape(EventSource source) {
			source.registerListener(
				new EventListener() {
					public void onEvent(Event e) {
						doSomething(e);
					}
				}
			)
		}
	}

像上面这种,当ThisEscape发布EventListener时,也隐含的发布了ThisEscape实例本身,因为在这个内部类的实例中包含了对ThisEscape实例的隐含引用.

  • 不要再构造过程中使this引用逸出.
  • 当对象在其构造函数中创建一个线程时,无论是显示创建(通过将它传给构造函数)还是隐式创建(由于Thread或Runnable是该对象的一个内部类),this
    引用都会被新创建的线程共享.
  • 在构造函数创建线程的话,最好不要立即启动它,而是通过一个start或initialize方法来启动

3.线程封闭

为保证线程安全,共享变量通常需要同步,一种避免使用同步的方式就是不共享数据.如果仅在单线程中访问数据,就不需要同步,这种技术被称为线程封闭
程序员需确保封闭在线程中的对象不会从线程中逸出.

3.1 Ad-hoc 线程封闭

定义:维护线程封闭性的职责完全由程序实现来承担.
Ad-hoc 线程封闭是非常脆弱的,因为没有任何一种语言特性能够将对象封闭到目标线程上.
当决定使用线程封闭技术时,通常是因为要将某个特定的子系统实现为一个 单线程子系统

3.2 栈封闭

定义:只能通过局部变量才能访问对象.
比Ad-hoc线程封闭更易于维护,也更健壮
如果在线程内部使用非线程安全的对象,那么该对象仍然是线程安全的.
维持对象引用的栈封闭时,程序员需要多做一些工作确保被引用对象不会逸出

3.3 ThreadLocal 类

定义: 维持线程封闭性的一种更规范的方法,这个类能使线程中的某个值与保存值的对象关联起来.
ThreadLocal提供了get与set方等访问接口或方法,这些方法为每个使用该变量的线程都存有一份独立的副本
ThreadLocal 对象通常用于防止对可变的单实例变量或全局变量进行共享.

	private static ThreadLocal<Connection> connectionHolder
		= new ThreadLocal<Connection>() {
			public Connection initialValue() {
				return DriverManager.getConnection(DB_URL);
			}
		};
	public static Connection getConnection() {
		return connectionHolder.get();
	}

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

4.不变性

如果对象的状态不会改变,那么就不会产生多线程的并发问题了.

不可变对象一定是线程安全的

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

当满足以下条件时,对象才是不可变的

  • 对象创建以后其状态就不能修改
  • 对象的所有域都是final类型.
  • 对象都是正确创建的(在对象的创建期间,this引用没有逸出)
@Immutable
public final class ThreeStooges {
    private final Set<String> stooges = new HashSet<>();

    public ThreeStooges() {
        stooges.add("Moe");
        stooges.add("Larry");
        stooges.add("Curly");
    }

    public boolean isStooge(String name) {
        return stooges.contains(name);
    }
}

4.1 Final 域

final域能够确保初始化过程的安全性,从而可以不受限制地访问不可变对象,并在共享这些对象时无须同步.
即使对象是可变的,通过将对象的某些域声明为final类型,仍然可以简化对状态的判断.

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

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

对于多个可变的变量,保证线程安全的一个方式是,将可变的变量封装到一个对象中,并将其声明为不可变的变量,下例中的OneValueCache 就是如此.


@ThreadSafe
public class VolatileCachedFactorizer implements Servlet {

    private volatile OneValueCache cache = new OneValueCache(null,null);

    @Override
    public void service(ServletRequest servletRequest, ServletResponse servletResponse)  {
        BigInteger i = extractFromRequest(req);
        BigInteger[] factors = cache.getFactors(i);
        if (factors == null) {
            factors = factors(i);
            cache = new OneValueCache(i,factors);
        }
        encodeIntoResponse(resp, factors);
    }
...
    class OneValueCache {
        private final BigInteger lastNumber;
        private final BigInteger[] lastFactors;

        public OneValueCache(BigInteger i, BigInteger[] factors) {
            this.lastNumber = i;
            this.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);
            }
        }
    }
}

VolatileCachedFactorizer 使用了OneValueCache 来保存缓存的数值及其因数.与cache相关的操作不会互相干扰,因为OneValueCache 是不可变的,并且在每条相应的代码路径只会访问它一次.

5.安全发布

如果需要多个线程之间共享变量,那么就要将这个对象安全的发布

不安全的发布:

public Holder holder;
public void initialize() {
	holder = new Holder(42);
}

任何线程都可以在不需要额外同步的情况下安全地访问不可变对象,即使在发布这些对象时没有使用同步.

5.1 安全发布的常用模式

可变对象必须通过安全的方式来发布.

要安全地发布对象,对象地引用以及对象地状态必须同时对其它线程可见.一个正确构造地对象可以通过以下方式来安全地发布.

  • 在静态初始化函数中初始化一个对象引用.
  • 将对象的引用保存到volatile类型的域或者AtomicReferance对象中.
  • 将对象的引用保存到某个正确构造对象的final类型域中.
  • 将对象的引用保存到一个由锁保护的域中.

线程安全库中的容器类提供了以下安全发布保证:

  • 通过将一个键或者值放入Hashtable,synchronizedMap或者concurrentMap中,可以安全的将它发布给任何从这些容器中访问它的线程.
  • 通过将某个元素放入Vector,CopyOnWriteArrayList,CopyOnWriteArraySet,synchornizedList或synchornizedSet中,可以将该元素安全地发布到任何从这些容器中访问该元素地线程.
  • 通过将某个元素放入BlockingQueue或者ConcurrentLinkedQueue中,可以将该元素安全地发布到任何从这些队列中访问该元素地线程.

通常,发布一个静态构造对象,最简单最安全的方式是使用静态地初始化器:

public static Holder holder = new Holder(42);

5.2 事实不可变对象

如果对象从技术上看是可变的,但其状态在发布后不会再改变,那么称这种对象为"事实不可变对象".
通过使用事实不可变对象,不仅可以简化开发过程,而且还能由于减少了同步而提高性能.

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

例如:

public Map<String,Date> lastLogin = Collections.synchronizedMap(new HashMap<String,Date>());

如果Date对象地值在被放入Map后就不会改变,那么synchronizedMap中地同步机制就足以使Date值被安全地发布,并且在访问这些Date值时不需要额外地同步.

5.3 可变对象

对于可变对象,不仅在发布对象时需要使用同步,在每次对象访问时同样需要使用同步来确保后续修改操作地可见性.

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

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

5.4 安全地共享对象

当发布一个对象时,必须明确地说明对象地访问方式.

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

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值