Java多线程第七篇--聊聊线程安全

152 阅读3分钟

什么样才叫线程安全

在之前的篇幅中,提到过很多次的线程安全这个术语,由此可以看到线程安全这个概念,他其实贯穿了多线程并发编程的始终,不管是平时的工作还是面试的时候,线程安全这个概念时常被提起。那怎么样才叫做线程安全呢?

这里有我一个比较浅薄的理解(可能不是很准确,哈哈~):无非就是有一块代码(可能是一个变量,或者是一个对象,也有可能是一段业务代码),经过一定的处理,可以安全的被多个线程同时(并发)使用,而且到最后都能得到我们想要的理想的正确的结果,那这块代码就是线程安全的。

以上的说法纯属个人见解,在这里呢,我摘抄下《深入理解java虚拟机》中的定义,原文是这么说的:

笔者(原文的作者哈,不是我~)认为《Java Concurrency In Practice》的作者Brian Goetz对线程安全有一个比较恰当的定义:“当多个线程访问一个对象时,如果不用考虑这些线程在运行是环境下的调度和交替执行,也不需要进行额外的同步,或者在调用方进行任何其他的协调操作,调用这个对象的行为都可以获得正确的结果,那这个对象是线程安全的”。

当然,这些都是线程安全的定义,其实真正的做到线程安全是一件很难的事情,更何况随着计算机性能的越来越高效,业务场景的要求也越来越快和高,想要做到线程安全,我们往往需要使出浑身解数了。那到底什么时候我们需要考虑线程安全,什么时候不需要,或者什么时候考虑的不需要那么深呢?我们来看看线程安全它的一个等级是如何划分的。

线程安全程度分类

在Java中,按照“线程安全”的安全程度由强到弱来排序,我们可以将java语言中各种操作共享的数据分为以下5类:不可变、绝对线程安全、相对线程安全、线程兼容和线程对立image.png

1.不可变

在Java中,如果一个变量被声明为不可变的,那他一定是线程安全的,因为任何调用者调用它,它的值都不会变化,永远也不会发生线程安全的问题,我们无需采取任何措施去保障线程安全。

class Demo{
    private final int i = 10;
    //这样一个变量i,无论多少线程访问,它的值永远不会变,也就不会发生线程安全问题
}

其实在Java中,还有很多类似的操作,比如我们的String类,被声明成final类型的,还有其他常用的枚举类型,以及Number的部分子类等等。如下图是String类的部分源码截图: image.png 再比如下面的图,String的常用方法substring,再怎么操作,他都不会影响本身String的值,返回的结果要么是自己本身,要么就是重新new的结果。 image.png

2.绝对线程安全

绝对的线程安全完全满足Brian GoetZ给出的线程安全的定义,这个定义其实是很严格的,一个类要达到“不管运行时环境如何,调用者都不需要任何额外的同步措施”通常需要付出很大的代价。比如我们常说的Vector这个类他是线程安全的类,因为它的一些方法比如下图展示

image.png image.png image.png image.png

诸如此类的方法 ,都是用同步器synchronized修饰的,所以我们认为这个类是线程安全,现实情况也确实如此。但是在一些极端的情况下呢,它会变得线程不安全,如下的示例:

public class VectorDemo {
	private static Vector<Integer> vector = new Vector<>();

	public static void main(String[] args) {
		while (true) {
			for (int i = 0; i < 10; i++) {
				vector.add(i);
			}

			Thread removeTh = new Thread() {
				@Override
				public void run() {
					for (int i = 0; i < vector.size(); i++) {
						vector.remove(i);
					}
				}
			};
			Thread showTh = new Thread() {
				@Override
				public void run() {
					for (int i = 0; i < vector.size(); i++) {
						System.out.println(vector.get(i));
					}
				}
			};
			removeTh.start();
			showTh.start();
		}

	}
}

运行效果如下:发生了java.lang.ArrayIndexOutOfBoundsException下标异常,说明什么,说明线程不安全了。。 image.png 那怎么办呢,只能增加代价,那就是在删除和展示的代码出增加同步块,如下: image.png image.png

这样就可以做到绝对线程安全,所以是不是我们又花了点代价才能做到的呢。

3.相对线程安全

相对线程安全,就是相对绝对线程安全来讲了,通常意义上所讲的一个类是“线程安全”的,比如上面我们举得Vector这个类就是相对线程安全的,我们在对这个对象单独操作的时候,他就是线程安全的,诸如这样的类还有HashTable、Collections的synchronizedCollection()方法保障的集合,再比如以后会介绍的J.U.C包底下的并发容器等。但是对于一些特殊的调用方式,如上面例子中所写的,我们就需要花费点额外的手段来保证线程安全了。

4.线程兼容

线程兼容就是我们通常意义上所讲的一个类不是线程安全的。

线程兼容是指对象本身并不是线程安全的,但是可以通过在调用端正确地使用同步手段来保证对象在并发环境下可以安全地使用。Java API中大部分的类都是属于线程兼容的。如与前面的Vector和HashTable相对应的集合类ArrayList和HashMap等。

5.线程对立

线程对立是指无论调用端是否采取了同步错误,都无法在多线程环境中并发使用的代码。由于java语言天生就具有多线程特性,线程对立这种排斥多线程的代码是很少出现的。

一个线程对立的例子是Thread类的supend()和resume()方法。如果有两个线程同时持有一个线程对象,一个尝试去中断线程,另一个尝试去恢复线程,如果并发进行的话,无论调用时是否进行了同步,目标线程都有死锁风险。正因此如此,这两个方法已经被废弃啦。常见的线程对立的操作还有System.setIn()、System.setOut()和System.runFunalizersOnExit()等。

如何实现线程安全

image.png

上图中,展示了在java中线程安全的解决方案

需要同步

互斥(阻塞)同步(悲观锁)

互斥同步是最常见的一种并发正确性保障手段。同步是指在多线程并发访问共享数据时,保证共享数据在同一时刻只被一个线程使用(同一时刻,只有一个线程在操作共享数据)。而互斥是实现同步的一种手段,临界区、互斥量和信号量都是主要的互斥实现方式。因此,在这4个字里面,互斥是因,同步是果;互斥是方法,同步是目的。

在java中,最基本的互斥同步手段就是synchronized关键字,synchronized关键字编译之后,会在同步块的前后分别形成monitorenter和monitorexit这两个字节码质量,这两个字节码指令都需要一个reference类型的参数来指明要锁定和解锁的对象。

此外,ReentrantLock也是通过互斥来实现同步。在基本用法上,ReentrantLock与synchronized很相似,他们都具备一样的线程重入特性。更进一步的是,ReentrantLock具有更高级的用法和锁的支持,比如更丰富的API接口;可实现等待可中断;可实现公平锁非公平锁;以及一个锁可以绑定多个等待条件。这个会在后续介绍同步器AQS/ReentrantLock会详细说明。

互斥同步最主要的问题就是进行线程阻塞和唤醒所带来的性能问题,因此这种同步也成为阻塞同步。从处理问题的方式上说,互斥同步属于一种悲观的并发策略,总是认为只要不去做正确地同步措施(例如加锁),那就肯定会出现问题,无论共享数据是否真的会出现竞争,它都要进行加锁。

非阻塞同步(乐观锁)

随着硬件指令集的发展,出现了基于冲突检测的乐观并发策略,通俗地说,就是先进行操作,如果没有其他线程争用共享数据,那操作就成功了;如果共享数据有争用,产生了冲突,那就再采用其他的补偿措施。(最常见的补偿错误就是不断地重试,直到成功为止),这种乐观的并发策略的许多实现都不需要把线程挂起,因此这种同步操作称为非阻塞同步。

非阻塞的实现CAS(compareandswap):CAS指令需要有3个操作数,分别是内存地址(在java中理解为变量的内存地址,用V表示)、旧的预期值(用A表示)和新值(用B表示)。CAS指令执行时,CAS指令指令时,当且仅当V处的值符合旧预期值A时,处理器用B更新V处的值,否则它就不执行更新,但是无论是否更新了V处的值,都会返回V的旧值,上述的处理过程是一个原子操作。

CAS看起来很美好,其实它也有它的问题,比如ABA问题:因为CAS需要在操作值的时候检查下值有没有发生变化,如果没有发生变化则更新,但是一个值原来是A,变成了B,又变成了A,那么使用CAS进行检查时会发现它的值没有发生变化,但是实际上却变化了。一旦发生了ABA问题我们就可以使用版本号来决定到底是不是之前版本对应的值了。 如下更新的SQL语句我们可以这么做:

update table set x=x+1, version=version+1 where id=#{id} and version=#{version}; 

无需同步

代码可重入(代码具有幂等性)

可重入代码(ReentrantCode)也称为纯代码(Pure Code),可以在代码执行的任何时刻中断它,转而去执行另外一段代码,而在控制权返回后,原来的程序不会出现任何错误。所有的可重入代码都是线程安全的,但是并非所有的线程安全的代码都是可重入的。

可重入代码的特点是不依赖存储在堆上的数据和公用的系统资源、用到的状态量都是由参数中传入、不调用 非可重入的方法等。我们可以通过一个简单的原则来判断代码代码是否具有重入性:如果一个方法,他的返回结果是可以预测的,只要输入了相同的数据,都能返回相同的结果,那我们就可以说这段代码具备可重入性。比如,同样的业务操作,更新某个状态为固定状态。

线程本地存储

如果一段代码中所需的数据必须与其他代码共享,那就看看这些共享数据的代码是否能保证在同一个线程中执行?如果能保证,我们就可以把共享数据的可见范围限制在同一个线程之内。这样无需同步也能保证线程之间不出现数据的争用问题。

比如,我们在一般的web项目中,在一个请求中,我们需要共享某个变量,能够在controller,service,Dao层中都能用,这个时候我们就可以利用Java提供的ThreadLocal的解决。

以上就是本篇对线程安全的介绍。

参考文献 《深入理解Java虚拟机》