Java中的线程安全问题

本文通过银行取款的并发问题,深入探讨了线程安全和线程不安全的概念,解释了线程安全问题产生的原因,并提供了避免线程安全问题的策略,包括使用synchronized关键字和Lock接口。此外,还讨论了Volatile修饰符在确保数据可见性但不保证原子性方面的局限性。
摘要由CSDN通过智能技术生成

当我们用线程来模拟两个用户从同一张银行卡里取钱时,我们有时会惊喜地发现当用户取出一定钱时,银行卡里的金额不减反增。如下图所示:

 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修饰变量,并不能避免线程安全问题。

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值