线程安全在Java中是一个很重要的课题。Java提供的多线程环境支持使用Java线程。我们都知道多线程共享一些对象实例的话,可能会在读取和更新共享数据的事后产生数据不一致问题。
线程安全
之所以会产生数据的不一致问题,是因为更新实例变量等类似的行为并非是原子操作。这类操作会有三个步骤:
- 读取当前的值
- 做一些必要的操作来获取更新的值
- 将更新的值写会变量之中
我们来看如下程序中多线程如何更新和共享数据:
package com.sapphire.threads;
public class ThreadSafety {
public static void main(String[] args) throws InterruptedException {
ProcessingThread pt = new ProcessingThread();
Thread t1 = new Thread(pt, "t1");
t1.start();
Thread t2 = new Thread(pt, "t2");
t2.start();
//wait for threads to finish processing
t1.join();
t2.join();
System.out.println("Processing count="+pt.getCount());
}
}
class ProcessingThread implements Runnable{
private int count;
@Override
public void run() {
for(int i=1; i < 5; i++){
processSomething(i);
count++;
}
}
public int getCount() {
return this.count;
}
private void processSomething(int i) {
// processing some job
try {
Thread.sleep(i*1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
在上面的程序中的的for循环里面,count的值每次是自增为1的,执行了四次,因为我们有两个线程,count
的值在两个线程执行完毕以后应该是8,但是当你运行上面的程序多次的话,你会发现count的值总是在6,7,8这几个数字之间。这是因为尽管count++
操作看起来是一个原子操作,但是实际上它并不是,所以导致了数据的冲突。
Java中的线程安全
线程安全就是指通过对一些处理令我们的程序能够安全的使用多线程的编程模型。以下有一些不同的方式来令我们的程序保证线程安全。
- 线程同步是最简单和常用的方法来确保线程安全
- 通过使用
java.util.concurrent.atomic
包中的原子类,可以确保操作的原子性 - 通过使用
java.util.concurrent.locks
包中的锁可以确保线程安全 - 使用线程安全的并发集合,比如
ConcurrentHashMap
来确保线程安全 - 通过
volatile
关键字来确保每次的变量使用都从内存中访问数据,而非访问线程缓存
Java 同步
同步是我们来获得线程安全的常用方法。JVM会保证同步的代码只会在同一时间仅仅由一个线程来执行。Java的关键字synchronized
就是用来创建同步代码的,在内部的执行的时候,synchronized
关键字会锁定对象或者类来确保只有一个线程来进入同步的代码块。
- Java的同步是通过锁定/解锁资源来实现的。在任何线程进入同步代码之前,线程必须请求对象的锁,而在代码执行结束的时候,线程再释放掉该锁,这样其他线程可以再次获取到这个锁。在某个线程执行同步代码的时候,其他的线程只能处于等待状态来等待被锁定的资源。
-
synchronized
关键字有两种用法,其一是在方法级别上声明,另一种是创建同步代码块。 - 当方法被同步的时候,JVM锁定的是对象,如果方法是静态的,那么就会锁定这个类。所以,通常最佳的实践是使用同步代码块来锁定需要同步的代码。
- 当创建同步代码块时,我们需要提供锁定的资源,可以是类本身,也可以是类的成员变量。
-
synchronized(this)
会在进入同步代码块之前锁定整个对象。 - 开发者应该使用最低级别的锁。举例来说,如果类中存在多个需要同步的地方,如果一个方法的访问就锁定了整个对象,那么其他同步代码块就无法被访问了。当我们锁定对象的时候,线程请求的锁是针对对象所有的成员变量的。
- Java的同步机制提供数据一致性的代价就是性能的损失,所以最好仅仅在最需要的时候使用。
- Java的同步机制仅仅在同一个JVM中生效的,所以当开发者尝试锁定不同JVM中的多个资源的时候,Java的同步机制是不会有效的。
- Java的同步机制可能会导致死锁的,需要注意防止产生死锁。
- Java的
synchronized
关键字不能同用于变量和构造函数。 - 在使用Java同步代码块的时候,最好通过创建一个额外的私有对象用来锁定,因为这个引用的对象并不会影响其他的代码。比如,如果开发者针对引用的对象包含一些set方法的调用的话,那么并行的执行可能会导致同步对象的改变。
- 开发者不应该使用任何常量池中的对象,比如
String
对象就不应该用来作为同步锁,因为大量的代码可能依赖于相同的字符串,线程就会尝试去请求String pool
中的对象锁,这样就会令不同的毫不相关的代码锁定相同的资源。
Java中不少的库也是通过
synchronized
来实现简单的同步,比如与ArrayList
相对应的Vector
,和HashMap
相对应的HashTable
甚至是常用的StringBuffer
和StringBuilder
,如果开发者查看过对应的源码,就会发现那些线程安全的类只是在方法上加上了synchronized
关键字而已。
下面是一些我们保证线程安全的做法:
//dummy object variable for synchronization
private Object mutex=new Object();
...
//using synchronized block to read, increment and update count value synchronously
synchronized (mutex) {
count++;
}
下面是一些代码帮助我们了解同步的机制:
public class MyObject {
// Locks on the object's monitor
public synchronized void doSomething() {
// ...
}
}
// Hackers code
MyObject myObject = new MyObject();
synchronized (myObject) {
while (true) {
// Indefinitely delay myObject
Thread.sleep(Integer.MAX_VALUE);
}
}
可以看出Hacker的代码是试着锁定myObject
的实例,而一旦获得了对应的对象锁,就永远不会释放对象锁,导致doSomething()
方法会永远阻塞,一直等待对象锁的释放。这就会导致系统死锁,导致服务拒绝(Denial of Service)。
再参考如下代码:
public class MyObject {
public Object lock = new Object();
public void doSomething() {
synchronized (lock) {
// ...
}
}
}
//untrusted code
MyObject myObject = new MyObject();
//change the lock Object reference
myObject.lock = new Object();
需要注意的是锁定的对象是一个共有的变量,一旦我们改变原对象所引用的对象,我们就可以任意的并行执行同步代码块中的内容了。如果开发者为私有的锁对象提供setter
方法的话,也会导致一样的问题。
再参考如下代码:
public class MyObject {
//locks on the class object's monitor
public static synchronized void doSomething() {
// ...
}
}
// hackers code
synchronized (MyObject.class) {
while (true) {
Thread.sleep(Integer.MAX_VALUE); // Indefinitely delay MyObject
}
}
这段代码与第一段代码很类似,前文已经提到了,静态的static方法会锁定类,所以一旦hacker代码的获得了MyObject的类锁,那么就会形成死锁。
下面是另一个例子:
package com.sapphire.threads;
import java.util.Arrays;
public class SyncronizedMethod {
public static void main(String[] args) throws InterruptedException {
String[] arr = {"1","2","3","4","5","6"};
HashMapProcessor hmp = new HashMapProcessor(arr);
Thread t1=new Thread(hmp, "t1");
Thread t2=new Thread(hmp, "t2");
Thread t3=new Thread(hmp, "t3");
long start = System.currentTimeMillis();
//start all the threads
t1.start();t2.start();t3.start();
//wait for threads to finish
t1.join();t2.join();t3.join();
System.out.println("Time taken= "+(System.currentTimeMillis()-start));
//check the shared variable value now
System.out.println(Arrays.asList(hmp.getMap()));
}
}
class HashMapProcessor implements Runnable{
private String[] strArr = null;
public HashMapProcessor(String[] m){
this.strArr=m;
}
public String[] getMap() {
return strArr;
}
@Override
public void run() {
processArr(Thread.currentThread().getName());
}
private void processArr(String name) {
for(int i=0; i < strArr.length; i++){
//process data and append thread name
processSomething(i);
addThreadName(i, name);
}
}
private void addThreadName(int i, String name) {
strArr[i] = strArr[i] +":"+name;
}
private void processSomething(int index) {
// processing some job
try {
Thread.sleep(index*1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
代码的执行结果如下:
Time taken= 15005
[1:t2:t3, 2:t1, 3:t3, 4:t1:t3, 5:t2:t1, 6:t3]
可以看出,String的数组出现了不一致问题,因为共享数据以及缺少同步。下面的代码可以改变addThreadName(...)
方法来令程序运行正确:
private Object lock = new Object();
private void addThreadName(int i, String name) {
synchronized(lock){
strArr[i] = strArr[i] +":"+name;
}
}
在我们修改了上面的代码以后,程序的输出结果如下:
Time taken= 15004
[1:t1:t2:t3, 2:t2:t1:t3, 3:t2:t3:t1, 4:t3:t2:t1, 5:t2:t1:t3, 6:t2:t1:t3]