线程安全是编写并发程序时最需要关注的重点之一。程序并行化固然可以提高运行效率,但前提是不能牺牲正确性。如果连程序的正确性都无法保证,并行化也就没有任何意义了。因此,线程安全就是并行程序的根本。之前的文章中介绍了使用volatile将多线程读写long会出现错误的情况进行了改善,但volatile关键字并不能真正保证线程安全,它只确保了修改的“可见性”,但当两个或多个线程同时修改同一个数据是,依然会产生冲突。
下面的代码中,两个线程同时对变量i进行累加操作,各执行1000000次,我们希望的执行结果当然是i == 2000000,但事实并非如此。在这段代码执行后,i的值大概率会小于2000000。这是因为两个线程同时对i进行写入时,尽管i已经声明了volatile,其中一个线程的结果会覆盖另外一个:
public class AccountingVol implements Runnable {
static AccountingVol instance = new AccountingVol();
static volatile i = 0;
public static void increase() {
i++;
}
@Override
public void run() {
for (int j = 0; j < 1000000; j++) {
increase();
}
}
public static void main(String[] args) {
Thread t1 = new Thread(instance);
Thread t2 = new Thread(instance);
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(i);
}
}
出现这种错误的原因如下图所示,t1和t2同时读取i的值为x,并各自计算得到i = x + 1,并将i的值写入,这时虽然i++执行了两次,但实际上i的值只增加了1。
想要解决这个问题,就必须保证同一时间只有一个线程进行这个操作。也就是说在t1进行写入时,t2不但不能写入,同时也不能读取。因为在t1操作完成之前,t2读取的一定是一个过期数据。Java提供了一个重要的关键字——synchronized来实现这个语义。
使用synchronized关键字可以实现线程间的同步。它的原理是对代码块加锁,同一时间只能有一个线程进入代码块执行,从而保证多线程间的安全性。
synchronized可以有多种用法:
- 指定加锁对象:对给定对象加锁,进入同步代码前要获得给定对象的值。
- 直接作用于实例方法:相当于对当前实例加锁,进入同步代码前要获得当前实例的锁。
- 直接作用于静态方法:相当于对当前类加锁,进入同步代码前要获得当前类的锁。
使用synchronized优化代码后,每当执行到increase()方法时,线程会试图获取instance实例的锁,获取成功后进入方法执行代码;如果获取失败则说明此时有其它线程在占用锁,需要等待其它线程操作完毕释放锁之后继续尝试获取对象锁,知道获取成功之后才可以进入方法执行。这样就保证了同时只有一个线程操作变量i:
public class AccountingVol implements Runnable {
static AccountingVol instance = new AccountingVol();
static volatile i = 0;
public synchronized void increase() {
i++;
}
@Override
public void run() {
for (int j = 0; j < 1000000; j++) {
increase();
}
}
// main函数参见第一段代码
}
上述代码中,synchronized关键字作用于一个实例方法。也就是说在进入increase()方法前,必须先获取当前实例的锁。在本例中指的就是instance对象。但如果将main函数修改为如下这样,则依然会出现并发错误:
public class AccountingVol implements Runnable {
static volatile i = 0;
public synchronized void increase() {
i++;
}
@Override
public void run() {
for (int j = 0; j < 1000000; j++) {
increase();
}
}
public static void main(String[] args) {
Thread t1 = new Thread(new AccountingVol());
Thread t2 = new Thread(new AccountingVol());
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(i);
}
}
出现错误的原因就在于此时虽然在increase()方法上添加了synchronized关键字,但两个线程中的AccountingVol类的实例不是同一个。也就是说,两个线程会分别获得自己内部AccountingVol实例的锁,此时他们持有的是不同的锁,因此无法保证线程安全。
如果想在这种情况下也保证线程安全,只需要将increase()方法再稍加修改,就能使其正确执行:
public static synchronized increase() {
i++;
}
添加了static关键字后,就相当于使用了synchronize的第三种用法,对AccountingVol类进行加锁操作。这样一来,即使两个线程持有不同的实例,但由于获取的锁是类的锁,线程间依然可以保证正确同步。
除了确保线程安全外,synchronized还保证了线程间的可见性和有序性。
可见性方面,Oracle官方在文档中说明了synchronized保证了可见性:
when a synchronized method exits, it automatically establishes a happens-before relationship with any subsequent invocation of a synchronized method for the same object. This guarantees that changes to the state of the object are visible to all threads.
并且,笔者在另一个网站上发现一段话:
Using synchronized makes that guarantee: the JVM makes sure, on exiting a synchronized block, that data written by that thread (before exiting the block) will be made available to any other thread that subsequently synchronizes on the same object.
上述两段话的大致意思是,当线程退出synchronized代码块之前,被这条线程写入的数据将会对之后的、对同一个对象synchronize的任何一条线程保证可见性。第二段话中提到了这是由JVM实现的。
有序性方面,由于synchronized限制每次只有一条线程可以访问同步块,因此无论同步块中的代码如何被乱序执行,串行语义都是保证一致的(Java内存模型),即执行结果是一样的。而其他线程又必须在获得锁后才能进入同步块,因此被synchronized限制的多个线程其实相当于是串行执行的。
参考文献
- 《Java高并发程序设计实战》.电子工业出版社.葛一鸣、郭超编著
- Oracle官方文档.docs.oracle.com/javase/tuto…
- www.javamex.com/glossary/sy…