当我们用线程来模拟两个用户从同一张银行卡里取钱时,我们有时会惊喜地发现当用户取出一定钱时,银行卡里的金额不减反增。如下图所示:
Thread-0用户第一次取后,银行卡里剩余金额应该只有950元,所以Thread-1用户第一次取时应该只有950元可从银行卡里取出,但结果却显示该用户还有1000元可从银行卡中取出。这是为什么呢?要回答这个问题,我们需要先了解线程安全与线程不安全。
线程安全:多个线程在执行同一段代码时采用加锁保护机制,使每次执行结果与单线程执行结果一致,不存在二义性。
线程不安全:不提供加锁保护机制,使得多个线程先后对同一个数据进行修改导致所得数据被污染。
一、那为什么会存在线程安全问题呢?
1.进程中的线程共享代码块和全局数据,存有竞争关系。
2.线程对内存中的数据不仅进行读取操作,还进行写操作,同时该写操作还不是原子操作。
3.多个线程处理任务时,系统会将CPU时间分成若干长度基本相同的时间片,依次分给各个线程,所以当某个线程的运行时间达到系统所分配的时间时,它就会失去CPU执行权利,重新进入就绪状态,等待被再次调度。
现在我们可以解释上面的取钱问题了。因为Thread-0用户和Thread-1用户存在竞争关系,每次取钱都需要对银行卡里的金额进行修改,即进行card.money-=50的操作,而该项操作不是原子操作。因为每次线程修改数据时都需要先从内存中读取数据放入寄存器中,然后对它进行修改,再将它重新放入内存中。所以若当Thread-0线程刚从内存中读取数据时就失去了CPU执行权利,则当Thread-1线程获得CPU执行权利时读取的数据仍是原来的数据。也就是说当Thread-0用户刚知道银行卡里的金额还没来得及取就被阻止时,Thread-1用户再去取时银行卡金额显示的仍然是原来的数据。
二、那怎么避免线程安全问题呢?
通过给线程提供加锁保护机制,使只有获得锁的线程才有机会运行某一段代码,而没有锁的线程即使获得了CPU执行权利,也无法运行该段代码,即有锁的线程虽然在运行该段代码时失去了CPU执行权利,但由于其它线程无法进入该段代码进行数据的修改。所以当该线程重新被调度时依然可以修改未被污染的数据,这样就避免了数据的二义性,使得运行结果跟单个线程运行结果一致。
给线程提供加锁保护机制,可以采用以下两种方式。
1.同步代码块
synchronized(监视器(锁)){
同步的代码
}
所有的对象都可以作为监视器,但要注意的是存在竞争资源的线程拥有的是同一把锁,如果每一个线程都各有一把锁,那么该加锁保护机制就失去了作用,达不到保护线程安全的效果。
所以如果当前类继承Thread类,那么当前类的对象就不能作为锁,因为开启不同线程时需要创建多个该类的对象。所以如果将该类的对象作为锁,那么锁就不唯一,无法起到保护作用。
以上面银行取钱为例,分别将this和Card类对象作为锁,运行结果如下:
以this为锁
以Card类对象为锁
但如果当前类不是继承Thread类而是实现Runnable接口,则用当前类对象为锁可以起到保护作用,因为开启不同线程时,当前类对象只被创建了一次,保证了锁的唯一性。
2.同步方法
public synchronized 返回值 方法名(参数类型 参数名,,){
方法体...
}
因为方法是属于当前类的,所以锁也是该类对象,也就是this。所以为了保证锁的唯一性,此时我们不能让当前类继承Thread类,而应继承Runnable接口。
3.Lock接口
不管是同步方法还是同步代码块,都只有获取锁的线程可以执行某项任务,而其他线程只能干巴巴
地等待。若获取锁的线程要等待IO或者调用了sleep(),中止了执行,则其它线程因没有锁也无法执行该段代码,这会影响线程的执行效率。使用Lock接口,实现主动加锁和释放锁的操作可以很好地规避这个问题。但使用Lock 需要进行释放锁操作,若线程处理任务时发生异常,就会导致死锁的出现。所以为了避免死锁的出现,我们需要使用try{}catch{}捕获异常,并将释放锁的操作写在finally中。
//Card类
public class Card {
int money=1000;
}
//User类
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
public class User implements Runnable {
Card card;
public User(Card card) {
this.card=card;
}
Lock lock=new ReentrantLock();//创建锁
public void run() {
//每次取50 用户1与用户2分别取10次 看每个用户每次还有多少钱可从银行卡中取出
for(int i=1;i<=10;i++) {
try {
Thread.sleep(30);
} catch (InterruptedException e) {
// TODO Auto-generated catch block
e.printStackTrace();
}
//解决线性安全问题 第一种方法使用同步代码块
// synchronized(this) {
// System.out.println(Thread.currentThread().getName()+"用户取时还有"+card.money+"元可从银行中取出");
// card.money-=50;//不是原子操作
// }
//第三种方法使用Lock接口
try {
lock.lock();
System.out.println(Thread.currentThread().getName()+"用户取时还有"+card.money+"元可从银行中取出");
card.money-=50;
}catch(Exception ex ) {
}finally {
lock.unlock();//释放锁
}
}
}
}
//Main类
public class Main {
public static void main(String[]args) {
Card card=new Card();
User user=new User(card);
Thread t1=new Thread(user);
Thread t2=new Thread(user);
t1.start();
t2.start();
}
}
最后再谈一下Volatile修饰符,Volatile能够保证操作数据的可见性,但不能保证操作数据的原子性,所以用它来修饰变量并不能避免线程安全问题。
三、那为何Volatile可以保证数据的可见性呢?
因为每个线程都有自己的独享工作内存,且他们都在自己的工作内存中对数据进行读取和写等操作 。所以当由多个线程共享的数据被某个线程修改后,其它线程因无法访问该线程的工作内存,所以其它内存无法得知被修改后的数据。若该共享数据被Volatile修饰,则某个线程修改该数据后,该数据会被强制保留在主存中,而主存是共享的,所以其它线程可以通过访问主存来获取被修改后的数据,保证了数据的可见性。
但Volatile并不能保证操作数据的原子性,比如现有线程A、B、C共享由Volatile修饰的变量num,num初始值为6,现在线程要对其进行num--操作。若线程C将num更改为5后,则线程A、B 将得知num值为5,当线程A刚好将num从主存中读入寄存器中就失去了CPU的执行权利,则线程B从主存中读取的num值还是为5,此时线程安全问题出现了。所以用Volatile修饰变量,并不能避免线程安全问题。