小言_互联网的博客

通过一个简单的java示例,来学习解决“线程不安全”的思路

455人阅读  评论(0)

一、什么是线程安全?

线程安全是多线程编程时的计算机程序代码中的一个概念。在拥有共享数据的多条线程并行执行的程序中,线程安全的代码会通过同步机制保证各个线程都可以正常且正确的执行,不会出现数据污染等意外情况。多个线程同时运行同一段代码,如果每次运行结果和单线程运行的结果是一样的,结果和预期相同,就是线程安全的,反之则是不安全的。

二、解决“线程不安全”的思路

先举个容易触发“线程不安全”的示例:

场景:抢票(很多个人同时抢,所以是多线程,并发请求)

示例:假设在抢票场景中,我们一共只有10张火车票,在最后一刻,我们已经卖出了9张火车票,仅剩最后一张。这个时候,系统发来多个并发请求,这些并发请求都同时读取到火车票剩余数量为1,然后都通过了这一个余量判断,最终导致超发。也就是说,本来我们只卖10张火车票,最多只生成10个订单,但因为线程不安全,用户的并发请求,导致抢票成功的用户订单超过了10个,这就是线程不安全。

1、线程不安全的代码:


  
  1. public class TicketRunnable implements Runnable {
  2. //剩余的票数
  3. static int count= 10;
  4. //抢到第几张票
  5. static int num= 0;
  6. //是否售完票
  7. boolean flag= false;
  8. @Override
  9. public void run() {
  10. // TODO Auto-generated method stub
  11. //票没有售完的情况下,继续抢票
  12. while (!flag) {
  13. sale();
  14. }
  15. }
  16. /**
  17. * 售票
  18. */
  19. private void sale() {
  20. if(count<= 0){
  21. flag= true;
  22. return;
  23. }
  24. //剩余的票数 减1
  25. count--;
  26. //抢到第几张票 加1
  27. num++;
  28. System.out.println(Thread.currentThread().getName()+ "抢到第"+num+ "张票,剩余"+count+ "张票。");
  29. }
  30. }

2、测试入口代码:


  
  1. public class Test {
  2. public static void main(String[] args) {
  3. // TODO Auto-generated method stub
  4. /**
  5. * 创建线程
  6. */
  7. TicketRunnable ticketRunnable= new TicketRunnable();
  8. //第1个人
  9. Thread t1= new Thread(ticketRunnable, "张三");
  10. //第2个人
  11. Thread t2= new Thread(ticketRunnable, "李四");
  12. //第3个人
  13. Thread t3= new Thread(ticketRunnable, "王五");
  14. //第4个人
  15. Thread t4= new Thread(ticketRunnable, "赵六");
  16. //第5个人
  17. Thread t5= new Thread(ticketRunnable, "田七");
  18. /**
  19. * 启动线程
  20. */
  21. t1.start();
  22. t2.start();
  23. t3.start();
  24. t4.start();
  25. t5.start();
  26. }
  27. }

3、运行结果:

 

解析:通过以上结果可知,这个线程是不安全的,用户的并发请求,导致多个用户同时抢到了第10张票。

方法一:悲观锁思路,使用synchronized关键字

悲观锁(Pessimistic Lock),每次去查询数据的时候都认为别人会修改,所以每次在查询数据的时候都会上锁,这样别人想拿这个数据就会阻塞直到它拿到锁。传统的关系型数据库里边就用到了这种锁机制,比如通过select ....for update进行数据锁定。

synchronized是一种同步锁(悲观锁),可修饰实例方法,静态方法,代码块。synchronized关键字代表这个方法加锁,相当于不管哪一个线程(例如线程A),运行到这个方法时,都要检查有没有其它线程B(或者C、 D等)正在用这个方法(或者该类的其他同步方法),有的话要等正在使用synchronized方法的线程B(或者C 、D)运行完这个方法后再运行此线程A,没有的话,锁定调用者,然后直接运行。具体使用方法如下表所示:

1、使用 synchronized同步方法,只需要在在sale()方法上添加 synchronized即可,如下:


  
  1. /**
  2. * 售票
  3. * 使用synchronized同步方法
  4. */
  5. private synchronized void sale() {
  6. if(count<= 0){
  7. flag= true;
  8. return;
  9. }
  10. //剩余的票数 减1
  11. count--;
  12. //抢到第几张票 加1
  13. num++;
  14. System.out.println(Thread.currentThread().getName()+ "抢到第"+num+ "张票,剩余"+count+ "张票。");
  15. }

2、使用synchronized同步代码块,效果和synchronized同步方法是一样的,线程代码修改为:


  
  1. public class TicketRunnable implements Runnable {
  2. //剩余的票数
  3. static int count= 10;
  4. //抢到第几张票
  5. static int num= 0;
  6. //是否售完票
  7. boolean flag= false;
  8. @Override
  9. public void run() {
  10. // TODO Auto-generated method stub
  11. //票没有售完的情况下,继续抢票
  12. while ( true) {
  13. //使用synchronized同步代码块
  14. synchronized ( this) {
  15. if(count<= 0){
  16. break;
  17. }
  18. //剩余的票数 减1
  19. count--;
  20. //抢到第几张票 加1
  21. num++;
  22. System.out.println(Thread.currentThread().getName()+ "抢到第"+num+ "张票,剩余"+count+ "张票。");
  23. }
  24. }
  25. }
  26. }

3、来看看最终的运行结果:

 

解析:通过以上结果可知,这个线程算是安全的,但却有弊端,没有公平性可言,有些用户压根抢不到票,有些用户则抢到了好几张票。 

结论:在“高并发 ”的抢购场景里使用悲观锁不太合理,用户并发请求,每个请求都需要等待“锁”,某些线程可能永远都没有机会抢到这个“锁”,这种请求就会死在那里。同时,这种请求会很多,瞬间增大系统的平均响应时间,结果是可用连接进程数被耗尽,系统陷入“雪崩”状态。

方法二:Lock锁机制

我们在使用synchronized关键字的时候会遇到下面这些问题:

  1. 不可控性,无法做到随心所欲的加锁和释放锁。
  2. 效率比较低下,比如我们现在并发的读取两个文件,读与读之间是互不影响的,但如果给这个读的对象使用synchronized来实现同步的话,那么只要有一个线程进入了,其他的线程都要等待。
  3. 无法知道线程是否获取到了锁。

而上面synchronized遇到的这些问题,Lock都可以很好的解决,并且jdk1.5以后,还提供了各种锁(例如读写锁),但有一点需要注意,使用synchronized关键字时,无须手动释放锁,但使用Lock必须手动释放锁。

1、我们修改线程代码:


  
  1. import java.util.concurrent.locks.Lock;
  2. import java.util.concurrent.locks.ReentrantLock;
  3. public class TicketRunnable implements Runnable {
  4. // 剩余的票数
  5. static int count = 10;
  6. // 抢到第几张票
  7. static int num = 0;
  8. // 是否售完票
  9. boolean flag = false;
  10. // 定义锁对象
  11. final Lock lock = new ReentrantLock();
  12. @Override
  13. public void run() {
  14. // TODO Auto-generated method stub
  15. // 票没有售完的情况下,继续抢票
  16. while (!flag) {
  17. sale();
  18. }
  19. }
  20. /**
  21. * 售票
  22. */
  23. private void sale() {
  24. // 加锁
  25. lock.lock();
  26. try {
  27. // 需要保证线程安全的代码放在try{}里
  28. if (count <= 0) {
  29. flag = true;
  30. return;
  31. }
  32. // 剩余的票数 减1
  33. count--;
  34. // 抢到第几张票 加1
  35. num++;
  36. System.out.println(Thread.currentThread().getName() + "抢到第" + num + "张票,剩余" + count + "张票。");
  37. } finally {
  38. // 解放锁
  39. lock.unlock();
  40. }
  41. }
  42. }

2、运行结果:

结论:从运行的结果来看,lock.lock()是对当前线程加锁,当线程执行完毕后调用lock.unlock()释放锁,这时候其他线程才可以去获取锁,至于是哪一个线程可以争抢到锁还是得看CPU的调度 。这个还是和悲观锁一样,没有公平性可言。

方法三:MySQL的事务

这个方法是在操作数据库时有效,使用MySQL的事务,锁住操作的行,其它事务必须等待此次事务提交后才能执行。

方法四:FIFO队列思路

我们直接将用户的请求放入队列中的,采用FIFO模式(First Input First Output,先进先出),这样的话,就不会导致某些请求永远获取不到锁的情况。先进先出(FIFO)的定义是先插入队列的元素也最先出队列,类似于排队的功能,从某种程度上来说这种队列也体现了一种公平性。

BlockingQueue:基于内存的阻塞队列,从阻塞这个词可以看出,在某些情况下对阻塞队列的访问可能会造成阻塞,被阻塞的情况主要有如下两种:

  • 当队列满了的时候进行入队列操作

  • 当队列空了的时候进行出队列操作

因此,当一个线程试图对一个已经满了的队列进行入队列操作时,它将会被阻塞,除非有另一个线程做了出队列操作;同样,当一个线程试图对一个空队列进行出队列操作时,它将会被阻塞,除非有另一个线程进行了入队列操作。

BlockingQueue的核心方法:

  • 插入方法:

    • add(E e) : 添加成功返回true,失败抛IllegalStateException异常
    • offer(E e) : 成功返回 true,如果此队列已满,则返回 false。
    • put(E e) :将元素插入此队列的尾部,如果该队列已满,则一直阻塞
  • 删除方法:

    • remove(Object o) :移除指定元素,成功返回true,失败返回false
    • poll() : 获取并移除此队列的头元素,若队列为空,则返回 null
    • take() :获取并移除此队列头元素,若没有元素则一直阻塞。
  • 检查方法

    • element() :获取但不移除此队列的头元素,没有元素则抛异常
    • peek() :获取但不移除此队列的头;若队列为空,则返回 null。

1、修改入口代码:


  
  1. import java.util.concurrent.BlockingQueue;
  2. import java.util.concurrent.LinkedBlockingQueue;
  3. public class Test {
  4. public static void main(String[] args) {
  5. // TODO Auto-generated method stub
  6. /**
  7. * 创建线程对象
  8. */
  9. TicketRunnable ticketRunnable = new TicketRunnable();
  10. // 第1个人
  11. Thread t1 = new Thread(ticketRunnable, "张三");
  12. // 第2个人
  13. Thread t2 = new Thread(ticketRunnable, "李四");
  14. // 第3个人
  15. Thread t3 = new Thread(ticketRunnable, "王五");
  16. // 第4个人
  17. Thread t4 = new Thread(ticketRunnable, "赵六");
  18. // 第5个人
  19. Thread t5 = new Thread(ticketRunnable, "田七");
  20. try {
  21. // 基于内存的阻塞队列
  22. BlockingQueue<Thread> queue = new LinkedBlockingQueue<Thread>();
  23. // 加入队列,put(E e)将元素插入此队列的尾部,如果该队列已满,则一直阻塞
  24. queue.put(t1);
  25. queue.put(t2);
  26. queue.put(t3);
  27. queue.put(t4);
  28. queue.put(t5);
  29. // 执行队列
  30. while (!queue.isEmpty()) {
  31. //take()获取并移除此队列头元素,若没有元素则一直阻塞。
  32. queue.take().start();
  33. }
  34. } catch (InterruptedException e) {
  35. // TODO Auto-generated catch block
  36. e.printStackTrace();
  37. }
  38. }
  39. }

不过,队列也有弊端,高并发的场景下,因为请求很多,很可能一瞬间将队列内存“撑爆”,然后系统又陷入到了“雪崩”状态。你可能会想,只要我设计一个极大的内存队列不就可以了吗,这或许算是个方法,但是,系统处理完一个队列内请求的速度根本无法和疯狂涌入队列中的数目相比,也就是说,队列内的请求会越积累越多,最终Web系统平均响应时候还是会大幅下降,系统还是陷入“雪崩”状态。

方法五:乐观锁思路

 乐观锁(Optimistic Lock), 每次去查询数据的时候都认为别人不会修改,所以不会上锁,但是在更新的时候会判断一下在此期间别人有没有去更新这个数据,可以使用版本号,时间戳等机制。乐观锁适用于多读的应用类型,这样可以提高吞吐量。

乐观锁有两种实现方式:

  1. 版本号机制
  2. CAS算法

版本号机制:这个数据所有请求都有资格去修改,但会获得一个该数据的版本号,只有版本号符合的才能更新成功,其他的返回失败。这样的话,我们就不需要考虑队列的问题,不过,它会增大CPU的计算开销,但综合来说,这是一个比较好的解决方案。

举例:比如下面是一张表,字段里有一个version版本号的属性

id num version
1 99 1

然后有两个线程,比如线程A,线程B,同时取id=1的记录。首先,线程A发起查询请求,语句如下:

select num,version from 表名 where id='1';

此时线程A获取到数据num=99,version=1,这时候线程B请求进来后,也发起同样的select查询,此时由于线程A尚未进行update更新,线程B请求获取到的数据也是一样的。

接下来,线程A进行update更新操作,更新语句如下:

update 表名 set num =${num} +1,version=${version}+1 where id='1' and version='1'

由于线程A使用了行级锁,此时version变成了2,线程B这时候发起update操作,由于version版本号已变成2,因些线程B使用以下语句,是无法进行update更新的:

update 表名 set num =${num} +1,version=${version}+1 where id='1' and version='1'

最终线程B会返回执行失败的结果。 

CAS算法:即compare and swap(比较与交换),是一种有名的无锁算法,也叫非阻塞同步(Non-blocking Synchronization)。CAS是乐观锁技术,当多个线程尝试使用CAS同时更新同一个变量时,只有其中一个线程能更新变量的值,而其它线程都失败,失败的线程并不会被挂起,而是被告知这次竞争中失败,并可以再次尝试。CAS算法涉及到三个操作数:

  • 需要读写的内存值 V
  • 进行比较的值 A
  • 拟写入的新值 B

当且仅当 V 的值等于 A时,CAS通过原子方式用新值B来更新V的值,否则不会执行任何操作(比较和替换是一个原子操作)。一般情况下是一个自旋操作,即不断的重试。CAS的操作是原子性的,所以多线程并发使用CAS更新数据时,可以不使用锁。JDK中大量使用了CAS来更新数据而防止加锁(synchronized 重量级锁)来保持原子更新。 

1、创建一个基于CAS算法的自旋锁 SpinLock.java

所谓自旋锁,是指线程反复检查锁变量是否可用,直到成功为止。由于线程在这一过程中保持执行,因此是一种忙等待,一旦获取了自旋锁,线程会一直保持该锁,直至显式释放自旋锁。


  
  1. import java.util.concurrent.atomic.AtomicReference;
  2. /**
  3. * CAS算法:自旋锁
  4. * @author Administrator
  5. *
  6. */
  7. public class SpinLock {
  8. /**
  9. * AtomicReference是对“对象”进行原子操作,用于规范原子变量的性质,可以保证你在修改对象引用时的线程安全性。
  10. * Atomic家族主要是保证多线程环境下的原子性,相比synchronized而言更加轻量级。
  11. */
  12. private AtomicReference<Thread> atomicReference = new AtomicReference<>();
  13. public void lock() {
  14. // 获取当前线程
  15. Thread current = Thread.currentThread();
  16. // 循环,直到atomicReference不为空
  17. while (!atomicReference.compareAndSet( null, current)) {}
  18. }
  19. public void unlock() {
  20. // 获取当前线程
  21. Thread current = Thread.currentThread();
  22. // 设置atomicReference为空
  23. atomicReference.compareAndSet(current, null);
  24. }
  25. }

AtomicReference是对“对象”进行原子操作,可以保证你在修改对象引用时的线程安全性。原理如下图:

2、修改线程代码:


  
  1. public class TicketRunnable implements Runnable {
  2. // 剩余的票数
  3. static int count = 10;
  4. // 抢到第几张票
  5. static int num = 0;
  6. // 是否售完票
  7. boolean flag = false;
  8. //自旋锁
  9. private SpinLock spinLock;
  10. //构造函数
  11. public TicketRunnable() {
  12. }
  13. public TicketRunnable(SpinLock lock) {
  14. this.spinLock = lock;
  15. }
  16. @Override
  17. public void run() {
  18. // TODO Auto-generated method stub
  19. // 票没有售完的情况下,继续抢票
  20. while (!flag) {
  21. sale();
  22. }
  23. }
  24. /**
  25. * 售票
  26. */
  27. private void sale() {
  28. try {
  29. //上锁
  30. spinLock.lock();
  31. if (count <= 0) {
  32. flag = true;
  33. return;
  34. }
  35. // 剩余的票数 减1
  36. count--;
  37. // 抢到第几张票 加1
  38. num++;
  39. System.out.println(Thread.currentThread().getName() + "抢到第" + num + "张票,剩余" + count + "张票。");
  40. } finally {
  41. //释放锁
  42. spinLock.unlock();
  43. }
  44. }
  45. }

3、修改入口代码:


  
  1. import java.util.concurrent.ExecutorService;
  2. import java.util.concurrent.Executors;
  3. public class Test {
  4. public static void main(String[] args) {
  5. // TODO Auto-generated method stub
  6. /**
  7. * 创建线程对象
  8. */
  9. SpinLock lock = new SpinLock();
  10. TicketRunnable ticketRunnable = new TicketRunnable(lock);
  11. // 第1个人
  12. Thread t1 = new Thread(ticketRunnable, "张三");
  13. // 第2个人
  14. Thread t2 = new Thread(ticketRunnable, "李四");
  15. // 第3个人
  16. Thread t3 = new Thread(ticketRunnable, "王五");
  17. // 第4个人
  18. Thread t4 = new Thread(ticketRunnable, "赵六");
  19. // 第5个人
  20. Thread t5 = new Thread(ticketRunnable, "田七");
  21. //启动线程
  22. t1.start();
  23. t2.start();
  24. t3.start();
  25. t4.start();
  26. t5.start();
  27. /**
  28. *
  29. //创建一个固定可重用的线程池
  30. ExecutorService executorService = Executors.newFixedThreadPool(5);
  31. //添加5个线程到线程池里
  32. for (int i = 0; i < 5; i++) {
  33. executorService.execute(new TicketRunnable(lock));
  34. }
  35. executorService.shutdown();
  36. */
  37. }
  38. }

4、运行结果:

CAS的缺点:

  • CPU开销较大 在并发量比较高的情况下,如果许多线程反复尝试更新某一个变量,却又一直更新不成功,循环往复,会给CPU带来很大的压力。

  • 不能保证代码块的原子性 CAS机制所保证的只是一个变量的原子性操作,而不能保证整个代码块的原子性,比如需要保证3个变量共同进行原子性的更新,就不得不使用Synchronized关键字了。

CAS容易引起ABA问题:

什么是ABA问题呢?假设有个共享变量的值为A,此时线程1去修改这个变量,在线程1修改的时候,线程2已经把这个变量修改成了B,然后线程3又把这个变量修改成了A。当线程1修改完毕时根据旧的预期值和共享内存的实际值进行比较得到的结果是相等的,则线程1认为变量没被修改过,则线程1提交成功,但其实变量已经被修改,此时的A非彼A,这就是ABA问题。最常见的就是资金问题,也就是别人如果挪用了你的钱,在你发现之前又还了回来,在这个过程中,你不知道的事情是,别人用你的钱去做了违法的事件,已经触犯了法律。

  • 普通场景下,ABA问题不会有什么影响,比如你在银行存款,但是你存的钱可能被银行拿去做了其他的事情,最后只是你的钱又返回来了,我们并不关心这个过程,我们只关心结果有没有变。 
  • 但如果是在一些特定场景,ABA问题就会有很大的影响,比如下图的例子:

解决ABA问题的方案: 

当每次修改共享变量提交时,不仅提交新值,还应给变量添加一个版本号或者一个时间戳。每次提交之前先判断旧的预期值是否和共享内存的实际值相等,相等时再比较版本号或者时间戳是否对应,都相等的情况下,再去提交修改。

Java可以使用AtomicStampedReference函数来解决ABA问题,AtomicStampedReference 的基本用法如下:


  
  1. //构造方法, 传入引用和戳
  2. public AtomicStampedReference(V initialRef, int initialStamp)
  3. //返回引用
  4. public V getReference ()
  5. //返回版本戳
  6. public int getStamp ()
  7. //如果当前引用 等于 预期值并且 当前版本戳等于预期版本戳, 将更新新的引用和新的版本戳到内存
  8. public boolean compareAndSet (V expectedReference,
  9. V newReference,
  10. int expectedStamp,
  11. int newStamp)
  12. //如果当前引用 等于 预期引用, 将更新新的版本戳到内存
  13. public boolean attemptStamp (V expectedReference, int newStamp)
  14. //设置当前引用的新引用和版本戳
  15. public void set (V newReference, int newStamp)

AtomicStampedReference的compareAndSet() 方法源代码解析:


   
  1. /**
  2. * 如果当前引用等于预期值并且当前版本戳等于预期版本戳, 将更新新的引用和新的版本戳到内存。
  3. * expectedReference:期望的引用(更新之前的原始值)
  4. * newReference:新的引用(将要更新的新值)
  5. * expectedStamp:期望版本号
  6. * newStamp:新的版本号
  7. */
  8. public boolean compareAndSet(V expectedReference,
  9. V newReference,
  10. int expectedStamp,
  11. int newStamp) {
  12. // 获取当前的元素与版本号
  13. Pair<V> current = pair;
  14. return
  15. expectedReference == current.reference && // 引用未改变,就是元素的值没有被改变
  16. expectedStamp == current.stamp && // 元素的版本号没有改变
  17. ((newReference == current.reference &&
  18. newStamp == current.stamp) || // 如果新的元素和新的版本号都与旧元素相等,则不需要更新
  19. casPair(current, Pair.of(newReference, newStamp))); // CAS更新
  20. }
  21. private boolean casPair(Pair<V> cmp, Pair<V> val) {
  22. return UNSAFE.compareAndSwapObject( this, pairOffset, cmp, val);
  23. }

 

原本打算用AtomicStampedReference来改造抢票示例的代码,但没有研究出来,有能力的大佬可以自行完善!


转载:https://blog.csdn.net/qq15577969/article/details/109706331
查看评论
* 以上用户言论只代表其个人观点,不代表本网站的观点或立场