JUC系列一:线程安全性

原创
2015/07/31 00:53
阅读数 106

###什么是线程安全

当多个对象访问某个类时,这个类始终能表现出正确的行为,那么就称这个类是线程安全的。

或者

当多个线程访问某个类时,无论运行时环境采用何种调度方式或者这些线程将如何交替执行,并且在主调代码中不需要任何额外的同步或协同,这个类都能表现出出正确的行为,那么这个类就是线程安全的。

###如何保证线程安全

要保证一个类的线程安全性,有三种方式:

  • 不在线程之间共享状态变量

  • 将状态变量修改为不可变的变量

  • 在访问状态变量时使用同步

####不在线程之间共享状态变量

下面的程序给出了一个简单的因数分解Servlet,这个Servlet从请求中提取出数值,执行因数分解,然后将结果封装在响应中。

public class MyServlet implements Servlet{
	public void service(ServletRequest request,ServletResponse response){
		int i=extract(req);
		int[] factors=factor(i);
		encodeIntoResponse(response,factors);
	}
}

我们都知道,像上面这种标准的(无状态的)Servlet在多线程访问下不会存在任何的多线程问题,因为访问无状态的对象的行为不会影响到其它线程中的操作,也就是说

无状态的对象一定是线程安全的

但在实际开发中,不可能只使用无状态的对象,当设计到有状态的对象时,我们不得不寻找其它的方法以确保程序的正确执行。

####将状态变量修改为不可变的变量

很明显,在多线程访问的类中,如果某个状态始终不会发生改变,则就不存在线程安全的问题。这里的不可变对象可以是final(即使是final对象也不代表不可变)修饰的对象,也可以是不存在更新操作的对象。

####在访问状态变量时使用同步

当我们往一个类中添加一个可变状态时,如果这个类被多线程所访问,那么就可能存在线程安全问题。 例如下面是一个访问计数器的Servlet:

public class MyServlet implements Servlet{
	private long count=0;
	public void service(ServletRequest request,ServletResponse response){
		//...
		++count;
		//...
	}
}

虽然count++是一种紧凑的语法,但这个操作并不是原子的,它包含三个独立的操作,读取count的值,将值加1,将结果赋值给count。我们可以假设当count为9时,如果两个线程同时执行到++count,那么就可能出现执行完后count的值为10而不是11;也就是说两次调用count只递增了一次,在多线程环境下,这个类没有始终表现出正确的行为,所以这是个线程不安全的类。这种由于不恰当的执行时序而出现不正确的结果有一个正式的名字,叫竞态条件

常见的竞态条件类型有两种,一种是上面的递增操作,也就是先读取再修改再写入。而另外一种就是先检查后执行,如下代码

public class LazyInitRace{
	private Object instance=null;
	public Object getInstance(){
		if(instance==null){
			instance=new Object();
		}
		return instance;
	}
}

假设线程A、B同时执行getInstance(),A看到instance为空,然后准备创建一个新的Object实例;B同样需要检测instance是否为空,而这又取决于A的执行结果和线程的调度方式,从而导致getInstance在某些情况下返回两个不同的对象,导致错误的结果。

我们把这两种操作叫复合操作,他们都包含一组需要以原子方式执行的操作,为了实现线程安全中的原子性,最主要的方法是保证代码以原子方式执行,Java中用于确保原子性的内置机制是加锁,但某些情况下也可以使用原子类。

#####原子类

public class MyServlet implements Servlet{
	private final AtomicLong count=new AtomicLong(0);
	public void service(ServletRequest request,ServletResponse response){
		//...
		count.incrementAndGet();
		//...
	}
} 

在java.util.concurrent.atomic包中包含了一些原子变量类,对这些类的方法调用都是原子性的。通过用AtomicLong来代替long类型的计数器,从而确保了代码的线程安全性。也就是说

当在无状态的类中添加一个状态时,如果这个状态完全由线程安全的对象来管理,那么这个类仍然是线程安全的

但是,当需要往类中添加的状态不止一个,且这几个状态之间又存在关联时,我们就不能使用原子变量类来管理了。对于上面的因数分解的例子,假设我们为了提高Servlet的性能,将最近的计算结果缓存下来,如果两个连续的请求对相同的因数进行分解时,就可以直接返回上一次的计算结果而无需重复计算。要实现该策略,需要使用两个状态:请求的值,分解的结果

public class MyServlet implements Servlet{
	private final AtomicReference<BigInteger> lastNumber=new AtomicReference<BigInteger>();
	private final AtomicReference<BigInteger[]> lastFactors=new AtomicReference<BigInteger[]>();
	public void service(ServletRequest request,ServletResponse response){
		BigInteger i=extract(request);
		if(i.equals(lastNumber.get())){
			encodeIntoResponse(response,lastFactors.get());
		}
		else{
			BigInteger[] factors=factor(i);
			lastNumber.set(i);
			lastFactors.set(factors);
			encodeIntoResponse(response,factors);
		}
	}
}

尽管上面使用了两个原子引用类来保存计算的数值和计算结果,但MyServlet中仍然存在这竞态条件。因为在这个类中有一个关系不变性被破坏了,这个关系不变性就是lastFactors和lastNumber是有关系的。我们假设A,B两个线程都在执行service方法,并且此时lastNumber=6,lastFactors=[2,3];如果线程A请求的数为4并且执行到lastNumber.set(i)后,还没有往后执行时,此时lastNumber=4,lastFactors=[2,3]。线程B这是如果请求的也是4的话,那么在执行到i.equals(lastNumber.get()),就会返回true,然后去执行encodeIntoResponse方法,这样线程B返回的结果就会是错误的[2,3]。因此,在这个类中,我们需要确保类中的不变性不被破坏,也就是说,当线程更新某一个变量时,需要在同一原子操作中更新另外一个变量。这种情况下,我们就需要用到锁。

#####加锁同步

Java提供了关键字synchronized来实现内置的锁机制,synchronized有两种使用方式,一种是同步代码块,另一种是锁方法。对于同步代码块,包含两个部分,一个是锁的对象引用,一个是锁保护的代码块。对于锁方法,也算是同步代码块的一种,只不过其保护的代码块是整个方法而已;如果是静态方法,那么锁的对象引用就是该方法所属类对应的Class对象,如果是非静态方法,锁的对象引用则是该方法所属的类对象。

每个Java对象都可以用作一个实现同步的锁,这些锁称之为内置锁。线程在进入同步代码块时需要获取锁对象,在退出代码块时会释放,并且在同一时刻,最多只有一个线程能持有这种锁,当线程A尝试获取一个友线程B持有的锁时,线程A必须等待或者阻塞,直到线程B释放这个锁。因此内置锁也保证了同步代码块中的代码会以原子方式执行。因此,对于上面的因数分解的例子,我们可以简单粗暴的使用synchronized来修饰service方法,从而保证了MyServlet类的线程安全性。

public class MyServlet implements Servlet{
	private BigInteger lastNumber;
	private BigInteger[] lastFactors;
	public synchronized service(ServletRequest request,ServletResponse response){
		//...........
	}
}

#####重入

Java的内置锁是可重入的,也就是说,如果线程A试图获取一个已经由它自己持有的锁,那么这个请求一定会成功。如下代码

public class Test{
	public synchronized void a(){
		
	}
	public synchronized void b(){
		a();
	}
}

可以想象,在线程执行a方法时,需要调用b方法,就得先获取一个已经持有的锁,如果内置锁不能重入的话,程序将会永远的阻塞下去,这显然是不对的。重入的实现可以为每个锁设置一个计数器来实现,当线程请求一个未被持有的锁(计数器为0)时,JVM记下锁的持有者,并将计数器的值设为1,如果同一个线程再次请求获取这个锁,则一定成功,并且将计数器的值加1,以此类推。当线程退出同步代码块时,计数器就相应的减1,知道计数器的值为0时,这个锁就被释放,可供其它的线程持用。

值得注意的是,并不是只有在写入共享变量时才需要对其进行加锁,而是应该将所有对共享变量的代码读写操作进行加锁,因为对于不同的线程来说,其同一个变量被多个线程共享,每个线程只保留了这个共享变量的一个副本在线程私有的工作内存中,当一个线程a修改了变量的值时,对于另外的线程b,在读取变量时,如果没有对这个共享变量的读取操作进行加锁,那么线程b就不一定能及时看见这个变量被线程a所修改后的值,这就是线程安全性中的可见性

展开阅读全文
打赏
0
0 收藏
分享
加载中
更多评论
打赏
0 评论
0 收藏
0
分享
返回顶部
顶部