《Java并发编程实践》笔记1——并发编程基础

1.线程安全定义:

当多个线程访问一个类时,如果不用考虑这些线程在运行时环境下的调度和交替执行,并且不需要额外的同步及在调用方代码不必做其他的协调,这个类的行为仍然是正确的,那么这个类就被称之为是线程安全的。简言之对于线程安全类的实例进行顺序或并发的一系列操作,都不会导致实例处于无效状态。

只有对象状态可变,且存在对象共享,才需要考虑线程安全。

可以通过下面的方法来确保线程安全:

(1).无状态对象:

无状态对象不包含域也没有引用其他类的域,一次特定计算的瞬时状态只会唯一存在本地变量中,这些本地变量存储在线程的栈中,只有执行线程才能访问,因此无状态对象永远是线程安全的。

(2).不可变对象:

不可变对象需要满足下面条件:

A.对象本身是final(避免被子类化),所有域都是final类型。

B.不可变对象的状态在创建后就不能再改变,每次对他们的改变都是产生了新的不可变对象的对象。

C.不可变对象能被正确地创建(在创建过程中没有发生this引用逸出)

不可变对象是线程安全的,不需要任何同步或锁的机制就可以保证安全地在多线程之间共享。

(3).原子变量:

介绍原子变量前首先介绍原子操作,假设有操作AB,如果从执行A的角度看,当其他线程执行B时,要么全部执行完成,要么一点都没有执行,这样AB互为原子操作,不满足原子操作要求的操作被称为复合操作,原子操作是线程安全的。

JDKjava.util.concurrent.atomic包中包括了原子变量类,这些类用来实现数字和对象引用的原子状态转换。

原子变量自身是线程安全的,但是如果一个不变约束涉及多个变量时,变量间不是彼此独立的,无论这些变量是否是原子变量都不能确保线程安全,而需要在同一个原子操作中更新这些相互关联的状态变量才能确保线程安全。

(4).正确使用线程同步:

通过使用synchronized关键字独占锁、显式锁、volatile等同步机制实现的相对线程安全。

2.多线程的竞争条件与数据竞争:

(1).竞争条件:

指多个线程或者进程在读写一个共享数据时结果依赖于它们指令执行的相对时序,即要想得到正确的结果,要依赖于幸运的时序。

最常见的竞争条件是“检查再运行”,使用一个潜在的过期值作为决定下一步操作的依据。

(2).数据竞争:

指没有使用同步来协调所有那些共享的非final域访问的情况,一个线程写入一个变量,可以被另一个线程读取;一个线程读取刚刚被另一个线程写入的变量,如果两个线程都没有使用同步,则会处于数据竞争的风险中。

不是所有的竞争条件都是数据竞争,也不是所有的数据竞争都是竞争条件,但它们都会引起并发程序以不可预期的方式失败。

3.内部锁:

Java提供了强制原子性的内置锁机制:synchronized块。

一个synchronized块有两部分:锁对象引用,以及该锁保护的代码块。

synchronized方法的锁是该方法所在的对象本身,静态的synchronized方法的锁是从Class对象上获取的锁。内部锁的特性如下:

(1).自动获得和释放:

每个java对象都可以隐式地扮演一个用于同步的锁的角色,这些内置的锁被称为内部锁或监视器锁,执行线程进入synchronized块之前自动获得锁,而无论是正常退出还是抛出异常,线程都会自动释放锁。因此获得内部锁的唯一途径是进入这个内部锁保护的同步块或方法。

(2).互斥性:

内部锁在java中扮演了互斥锁的角色,即至多只有一个线程可以拥有锁,没有获取到锁的线程只能等待或阻塞直到锁被释放,因此同步块可以线程安全地原子执行。

(3).可重入性:

可重入是指对于同一个线程,它可以重新获得已有它占用的锁。

可重入性意味着锁的请求是基于”每线程”而不是基于”每调用”,它是通过为锁关联一个请求计数器和一个占有它的线程来实现。

可重入性方便了锁行为的封装,简化了面向对象并发代码的开发,可以防止类继承引起的死锁,例子如下:

public class Widget {
	public synchronized void doSomething(){
		......
	}
}

public class LoggingWidget extends Widget {
	public synchronized void doSomething(){
		System.out.println(toString() + “: calling doSomething”);
		super.doSomething();
	}
}

子类LoggingWidget覆盖了父类Widgetsynchronized类型的doSomething方法,并调用了父类的中的同步方法,因此子类LoggingWidget和父类Widget在调用doSomething方法之前都会先获取Widget的锁,若内部锁没有可重入性,则super.doSomething的调用就会因为无法获得锁而被死锁。

4.内存可见性:

可见性是关于在哪些情况下,一个线程执行的结果对另一个线程是可见的问题。

在单线程的情况下,程序按顺序执行保证内存可见性,但是在多线程环境下,为了优化性能,在没有同步的情况下,java存储模型允许编译器进行指令重排序,指令重排序的结果是程序指令的执行顺序是不确定的,结果会导致内存可见性问题。

内存可见性不仅避免一个线程修改其他线程正式使用的对象状态,而且还保证一个线程修改了对象的状态之后,其他的线程能够真正看到改变,可以通过如下方式保证多线程的内存可见性:

(1).锁:

锁不仅仅是关于同步与互斥的的,也是关于内存可见性的,为了保证所有线程都能看到共享的、可变变量的最新值,读取和写入线程必须使用公共的锁进行同步,锁确保释放锁之前对共享数据做出的更改对于随后获得该锁的另一个线程是可见的 

(2).volatile变量:

volatile是一种弱形式的同步, volatile确保对一个变量的更新以可预见的方式告知其他线程。当一个域被声明为volatile类型后,编译器与运行时会监视这个变量:它是共享的,而且对它的操作不会与其他内存操作一起被重排序,volatile变量不会缓存在寄存器或者缓存在其他处理器隐藏的地方,因此读一个volatile类型的变量时,总是返回由某一线程所写入的最新值。

出于简易性或可伸缩性的考虑,您可能倾向于使用 volatile 变量而不是锁。当使用 volatile 变量而非锁时,某些习惯用法更加易于编码和阅读。此外,volatile 变量不会像锁那样造成线程阻塞,因此也很少造成可伸缩性问题。在某些情况下,如果读操作远远大于写操作,volatile 变量还可以提供优于锁的性能优势,但是请注意volatile只保证可见性,不保证原子性。

只有满足如下条件才能使用volatile 变量:

A. 对变量的写操作不依赖于当前值。

B. 该变量没有包含在具有其他变量的不变式中。

5.发布和逸出:

(1).发布:

发布一个对象是指使该对象能够被当前范围之外的代码所使用,例如将一个引用存储到其他代码可以访问的地方;在一个非私有的方法中返回该引用;将该对象传递到其他类的方法中等。

最常见的发布方式是将对象的引用存储到公共静态域中,例如:

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

(2).逸出:

逸出是指一个对象在尚未准备好时就将它发布。

对象逸出会导致对象的内部状态被暴露,可能危及到封装性,使程序难以维持稳定;若发布尚未构造完成的对象,可能危及线程安全问题。

最常见的逸出是this引用在构造时逸出,导致this引用逸出的常见错误有:

A.在构造函数中启动线程:

当对象在构造函数中显式还是隐式创建线程时,this引用几乎总是被新线程共享,于是新的线程在所属对象完成构造之前就能看见它。

避免构造函数中启动线程引起的this引用逸出的方法是不要在构造函数中启动新线程,取而代之的是在其他初始化或启动方法中启动对象拥有的线程。

B.在构造方法中调用可覆盖的实例方法:

在构造方法中调用那些既不是private也不是final的可被子类覆盖的实例方法时,同样导致this引用逸出。

避免此类错误的方法时千万不要在父类构造方法中调用被子类覆盖的方法。

C.在构造方法中创建内部类:

在构造方法中创建内部类实例时,内部类的实例包含了对封装实例的隐含引用,可能导致隐式this逸出。例子如下:

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

上述例子中的this逸出可以使用工厂方法来避免,例子如下:

public class SafeListener {
	private final EventListener listener;

	public 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;
	}
}

6.线程封闭:

访问共享的、可变的数据要求使用同步。线程封闭是一种将数据仅在单线程中访问而不共享,不需要任何同步的最简单的实现线程安全的方式。

当对象(无论本身是否线程安全)封闭在一个线程中,会自动成为线程安全,线程封闭的常用做法有:

(1).栈限制:

栈限制是线程封闭的一种特例,只能通过本地变量才可以触及对象,本地变量使对象限制在执行线程中,存在于执行线程栈,其他线程无法访问这个栈,从而确保线程安全。栈限制的例子如下:

public int loadTheArk(Collection<Animal> candidates){
	SortedSet<Animal> animals;
	int numPairs = 0;
	Animal candidate = null;

	//animals被限制在本地方法栈中
	animals = new TreeSet<Animal>(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;
}

注意上面的栈限制例子中animals不能逸出,否则就会破坏限制性。

(2).ThreadLocal

ThreadLocal线程本地变量是一种规范化的维护线程限制的方式,它允许将每个线程与持有数值的对象关联在一起,为每个使用它的线程维护一份单独的拷贝。ThreadLocal提供了setget访问器,get总是返回由当前线程通过set设置的最新值。

ThreadLocal线程本地变量通过用于防止在基于可变的单例(singleton)或全局变量的设计中,出现不正确的共享。ThreadLocal线程本地变量的例子如下:

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

7.向已有的线程安全类添加功能:

如果JDK或者第三方类库提供的线程安全类只能满足我们大部分的要求,即不能完全满足要求时就需要对其进行添加新操作,这个看似简单的问题往往会引起线程安全问题。

以向一个线程安全的List添加一个原子的缺少即加入操作为例,有如下方法:

(1).修改原始类:

添加一个新原子操作最安全的方式就是修改原始类,在原始类中添加新操作,但是软件设计的开放闭合原则以及有可能无法得到源码(或无法自由修改源码)等问题决定修改原始类是最不可能的。即便可以修改原始类,也要保证理解原有的同步策略,维持原有的设计。

(2).扩展原始类:

如果原始类在设计上是可扩展的(没有声名为final,即允许继承),则扩展原始类并添加新方法,例子如下:

public class BetterVector<E> extends Vector<E> {
	public synchronized boolean putIfAbsent(E x){
		boolean absent = !contains(x);
		if(absent){
			add(x);
		}
		return absent;
	}
}

扩展原始的线程安全类要特别小心,虽然做法非常直观,但是一定要明白原始的线程安全类的同步策略,新扩展的类要使用和原始类相同的锁来控制对基本类状态的并发访问,这种访问有很大的局限性,如果原始类使用的是内部私有锁同步策略或者没有告知使用者同步策略,则该方式是不能支持的。

Vector是使用内部锁来控制并发访问的,因此上面代码中BetterVector也使用内部锁控制并发访问是可以维持Vector同步策略。

(3).客户端加锁:

对于一个由Collections.synchronizedList封装的ArrayList,客户端不知道同步封装工厂方法返回的List对象的同步策略的时候,前面所介绍的扩展原始类方案就无法支持,这时我们就需要将新增功能添加在客户端,并在客户端进行加锁同步。

客户端加锁要非常小心,不注意就会发生错误,下面例子演示一个常见的错误:

public class ListHelper<E> {
	public List<E> list = Collections.synchronizedList(new ArrayList<E>());

	public synchronized boolean putIfAbsent(E x) {
		poolean absent = !list.contains(x);
		if (absent) {
			list.add(x);
		}
		return absent;
	}
}

上述代码错误在于使用了与线程同步List不同的锁,上面代码的putIfAbsent方式使用的是ListHelper对象的内部锁,而线程同步List的其他原子操作肯定用的不是ListHelper对象内部锁,因此putIfAbsent对于List的其他操作而言并不是原子化的,上述代码是很多人经常不小心犯的错误。

避免上述的错误的办法是让客户端的putIfAbsent方法所使用的锁与List的其他操作所使用的锁是同一个锁,正确的代码如下:

public class ListHelper<E> {
	public List<E> list = Collections.synchronizedList(new ArrayList<E>());

	public boolean putIfAbsent(E x) {
		synchronized (list) {
			poolean absent = !list.contains(x);
			if (absent) {
				list.add(x);
			}
			return absent;
		}
	}
}

(4).组合:

面向对象有两种常用的扩展方式:继承(is-a)和组合(has-a),设计原则也经常推荐优先使用组合,除非情况非常合适,否则尽量少使用继承。

组合对于向现有线程安全类添加新功能时同样适合,通过添加一层额外的锁层,组合对象将操作委托给底层List实例,无论底层List实例是否实现线程安全,组合对象的putIfAbsent方法都可以保证操作的原子性,例子如下:

public class ImprovedList<T> implements List<T>{
	private final List<T> list;
	public ImprovedList<T>(List<T> list){
		this.list = list;
	}
	public synchronized boolean putIfAbsent(T x){
		boolean absent= !list.contains(x);
		if(absent){
			list.add(x);
		}
		return absent;
	}
	public synchronized void clear(){
		list.clear();
	}
	......
}
阅读更多
换一批

没有更多推荐了,返回首页