Java多线程(四):线程安全问题
2021/7/3 20:51:49
本文主要是介绍Java多线程(四):线程安全问题,对大家解决编程问题具有一定的参考价值,需要的程序猿们随着小编来一起学习吧!
目录:
- 1. 线程间的数据竞争
- 2. synchronized 关键字
- 2.1 synchronized 实现原理
- 2.1 synchronized 方法锁、对象锁、类锁
- 3. 锁(Lock)
- 3.1 java.util.concurrent.locks.Lock 接口
- 3.2 可重入
- 3.3 可中断
- 3.4 设置等待时间 & 公平锁
- 4. 死锁
- 5. 线程间通讯
1. 线程间的数据竞争
在使用多线程编程时,线程安全是我们必须要考虑的一个因素。对于线程安全,简单的来说,就是当一个变量被多个线程共享时,有可能会出现多个线程同时操作此变量的情况,这里就产生了竞争。竞争的结果就是变量最终的值不一定会是我们预期的值,可能在竞争的时候就已经“损坏”了。
举一个简单的例子:
public class ThreadSecurity implements Runnable { private int totalNumber = 0; @Override public void run() { for (int i = 0; i < 10000; i++) { totalNumber++; } } public int getTotalNumber() { return totalNumber; } public static void main(String[] args) { Thread[] threads = new Thread[10]; ThreadSecurity count = new ThreadSecurity(); for (int i = 0; i < 10; i++) { threads[i] = new Thread(count); threads[i].start(); } for (int i = 0; i < 10; i++) { try { // 等待所有线程执行完成 threads[i].join(); } catch (InterruptedException e) { e.printStackTrace(); } } // 输出计数结果,预期值应该为:10000 * 10 = 100000 System.out.println(count.getTotalNumber()); } }
程序运行结果如下:
程序最终并没有输出我们预期的结果100000,并且,如果尝试多次运行程序的话,每一次得到是输出几乎都是不同的(严格的说,应该都不大于100000)。
上述例子就是线程安全的一个典型例子。当有10个线程同时操作一个字段totalNumber
时,线程间可能产生竞争,造成线程间数据不同步的问题。
入上图所示,由于可能存在线程间竞争,当线程A从主内存中取到totalNumber
后,在其还未计算完毕并将计算结果同步回主内存时,线程B也从主内存中取了totalNumber
,但此时totalNumber
还是原来的值。也就是说,当线程A、B计算结束后,均会将X+1同步回主内存,即两次加1操作,最终totalNumber
的值却只增加了1。
当然,可以利用volatile
关键字保证主内存和工作内存中变量的一致性,但由于并不是所有的操作都是原子的,加上volatile
关键字后还是可能会出现线程安全问题(可参考: volatile变量的线程安全问题)
关于主内存和工作内存,可参考: Java内存模型
2. synchronized 关键字
在JDK 1.5 之前,可以通过synchronized 关键字解决线程安全问题。
public class ThreadSecurity implements Runnable { private int totalNumber = 0; // 在方法上加synchronized关键字 @Override public synchronized void run() { for (int i = 0; i < 10000; i++) { totalNumber++; } } /* public void run() { // 利用synchronized同步块实现线程间同步 synchronized (this) { for (int i = 0; i < 10000; i++) { totalNumber++; } } } */ public int getTotalNumber() { return totalNumber; } public static void main(String[] args) { Thread[] threads = new Thread[10]; ThreadSecurity count = new ThreadSecurity(); for (int i = 0; i < 10; i++) { threads[i] = new Thread(count); threads[i].start(); } for (int i = 0; i < 10; i++) { try { // 等待所有线程执行完成 threads[i].join(); } catch (InterruptedException e) { e.printStackTrace(); } } // 输出计数结果,预期值应该为:10000 * 10 = 100000 System.out.println(count.getTotalNumber()); } }
执行结果:
通过上述的两种synchronized
的使用方法,很好的解决的线程间同步的问题,最终得到了预期的结果。
2.1 synchronized 实现原理
synchronized 通过一种“互斥同步”的机制来保证线程间的安全。 简单的来说,就是在操作共享变量之前,首先需要获得对应的锁(同一时刻,只能有一个线程持有该锁),只有拥有该锁的线程才能对变量进行操作。
如上面的流程所示,synchronized
关键字保证了同一时刻,只能有一个线程对共享变量进行操作,避免了数据竞争,实现了线程间的同步。
2.1 synchronized 方法锁、对象锁、类锁
如上面的例子所示,synchronized
关键字有以下的使用方法:
- 直接加在方法上:方法锁
- 使用
synchronized
同步块:synchronized(object) { ... }
锁类型 | 功能 |
---|---|
方法锁 | 每个带有synchronized关键字的实例方法都必须首先获得调用该方法的实例的锁,然后才能执行具体的方法;方法执行结束后,锁将被释放,期间其它线程无法再次获得该锁。 |
对象锁 | 对象锁与方法锁类似,在进入synchronized同步块前需要获得括号中普通对象的锁(this代表本实例),执行结束后同样会释放锁。 |
类锁 | 如果是静态方法上加synchronized关键字,或者synchronized同步块修饰的是Class对象,那么方法或同步块中的代码运行前就需要先获得该Class对象对应的锁(Class对象可以作为类的唯一标识符)。 |
3. 锁(Lock)
针对线程安全问题,JDK 1.5 为我们提供了一个新的更灵活的工具Lock,它的灵活性主要体现在以下方面:
- 可重入
- 提供了中断响应功能
- 可设置申请锁时的等待时间
- 可以提供公平锁
3.1 java.util.concurrent.locks.Lock 接口
Lock 接口规定的一个锁需要拥有的基本功能,其中定义了6个方法,如下所示:
public interface Lock { void lock(); // 尝试获得锁 void lockInterruptibly() throws InterruptedException; // 尝试获得可中断锁 boolean tryLock(); // 尝试获得锁,如获取失败则立刻返回false,并不会挂起等待 boolean tryLock(long time, TimeUnit unit) throws InterruptedException; // 与tryLock()类似,但此方法可以设置等待时间 void unlock(); // 释放锁 Condition newCondition(); // 由于线程间通讯 }
Java 为我们 提供了一些 Lock 接口的默认实现:
从其命名中,我们便不难猜测它们各自的作用(读锁、写锁、可重入锁)。这里选择比较常见的可重入锁ReentrantLock
(Reentrant,可重新进入)作为例子,以具体介绍锁的用法。
继续以上述的计数代码为例,这里将其改造为Lock的解决方法:
public class ThreadSecurity implements Runnable { private int totalNumber = 0; private static Lock countLock = new ReentrantLock(); @Override public void run() { countLock.lock(); for (int i = 0; i < 10000; i++) { totalNumber++; } countLock.unlock(); } public int getTotalNumber() { return totalNumber; } public static void main(String[] args) throws ClassNotFoundException { Thread[] threads = new Thread[10]; ThreadSecurity count = new ThreadSecurity(); for (int i = 0; i < 10; i++) { threads[i] = new Thread(count); threads[i].start(); } for (int i = 0; i < 10; i++) { try { // 等待所有线程执行完成 threads[i].join(); } catch (InterruptedException e) { e.printStackTrace(); } } // 输出计数结果,预期值应该为:10000 * 10 = 100000 System.out.println(count.getTotalNumber()); } }
Lock的使用比较简单,首先我们需要实例化一个具体的实现了Lock接口的对象,然后就可以通过此对象进行加锁和释放锁。需要注意的是,这里的锁是需要我们手动释放的,也就是说,为了保证在获取了锁之后,能够安全的将其释放,最好countLock.unlock()
操作放在finnally
语句块中!
try { // 业务代码 } catch (Exception e) { // 可能会出现的异常 // 异常处理 } finally { // 释放锁 countLock.unlock(); }
3.2 可重入
当然,正如它的名字所示,ReentrantLock
是可重入的(就如字面意思所表达的,即可以多次进入):
countLock.lock(); countLock.lock(); // 重入 countLock.lock(); // 重入了几次锁,就需要释放几次 countLock.unlock(); countLock.unlock(); countLock.unlock();
这里需要注意的是,如果我们多次进入了同一个锁,就需要相应的释放多少次! 只有这样,这个锁才有可能重新被其他线程所获得。
为帮助理解,可以想象锁中存在一个重入的计数器,每次lock()
都会让此计数器加1,unlock()
操作都会让其减1;只有当该计数器的值减少到0的时候,此锁才是空闲的,才可以被其它线程获得。
3.3 可中断
对于关键字synchronized来说,如果一个线程在等待锁,那么结果只有两种情况,要么它获得这把锁继续执行,要么它就保持等待。而使用重入锁,则提供另外一种可能,正如 Lock 接口所定义的,它给我们提供了中断处理的功能,通过lockInterruptibly()
方法可以获得一个可以响应中断的锁。
public class LockInterrupt implements Runnable { private static Lock lock = new ReentrantLock(); @Override public void run() { lock.lock(); try { String time = new SimpleDateFormat("HH:mm:ss").format(new Date()); System.out.println(time + ":" +Thread.currentThread().getName() + " Enter"); Thread.sleep(9000); } catch (InterruptedException e) { e.printStackTrace(); } finally { lock.unlock(); } } public static void main(String[] args) throws InterruptedException { LockInterrupt runnable = new LockInterrupt(); Thread thread1 = new Thread(runnable, "Thread1"); Thread thread2 = new Thread(runnable, "Thread2"); System.out.println(new SimpleDateFormat("HH:mm:ss").format(new Date())); thread1.start(); Thread.sleep(1000); thread2.start(); Thread.sleep(3000); thread1.interrupt(); } }
执行结果:
通过Lock提供的中断响应功能,可以更灵活的控制锁。例如,如果程序遇到了死锁,那么利用中断就可以很轻松的解决它。
3.4 设置等待时间 & 公平锁
如果申请锁的时候,发现锁当前已被另外一个线程占用,而申请锁的**线程又不想一直等待下去,而是想利用等待的时间去干一些其它的事情。**这种情况下,就可以通过设置等待时间来实现这个功能。
通过Lock接口定义的tryLock()
方法,就可以设置等待时间。
// 申请获得锁,如果未成功,则放弃并返回false; lock.tryLock(); // 从捕获的异常就可以看出,等待的过程是可以被中断的 try { // 申请获得锁,如果5s内未成功,则放弃并返回false; boolean flag = lock.tryLock(5, TimeUnit.SECONDS); } catch (InterruptedException e) { e.printStackTrace(); }
**在大多数的情况下,锁的申请都是不公平的。**也就是说,线程A首先申请了锁,接着线程B也申请了同一个锁,当锁可用的时候,并不能根据申请的顺序判断谁会得到锁。也就是说,先申请的不一定先获得锁。
当然,如果想要获得一个公平的锁,Lock也为我们提供的响应的实现机制。查看ReentrantLock
的源码,可用看到其中有这样一个构造方法:
public ReentrantLock(boolean fair) { sync = fair ? new FairSync() : new NonfairSync(); }
参数fair
由于表示此锁是否为一个公平锁:
- true:公平
- false:不公平
需要注意的是,除非是十分必要的场合,否则尽量不要使用公平锁!
原因很简单:要实现公平锁就要求系统维护一个有序队列,因此公平锁的实现成本比较高,性能却非常低下。
4. 死锁
虽然Java为我们提供了十分遍历的线程同步机制,但在使用过程中还是有一些需要注意的地方,死锁就是其中一个比较麻烦的地方。顾名思义,死锁了意思就是由于线程间一些特定的操作,导致某些锁永远也不可能再被获得。
造成死锁的情况有很多,可以简单的这样理解死锁产生的过程:
- 线程A、B的工作都需要两个锁lock1、lock2的支持;
- 线程A首先获得了lock1,开始了它的工作;
- 后面线程B也开始工作,不同的是,线程B首先需要获得lock2;
- A工作到一半,发现需要lock2了,这时候它就去申请lock2,但此时lock已被B持有,因此A挂起等待(并没有释放lock1);
- 同样的,当B需要lock1的时候,B也去申请lock1,但此时lock1还被A持有着,因此B也挂起等待(并没有释放lock2);
- 至此,死锁就产生了,正常情况下lock1和lock2永远不会被释放(可用中断打破死锁)。
转换成代码,此过程如下所示:
public class Deadlock { public static ReentrantLock lock1 = new ReentrantLock(); public static ReentrantLock lock2 = new ReentrantLock(); public static void main(String[] args) throws InterruptedException { // 线程1:先获取lock1,再获取lock2 Thread thread1 = new Thread(() -> { try { lock1.lockInterruptibly(); Thread.sleep(8000); lock2.lockInterruptibly(); System.out.println(Thread.currentThread().getName() + "成功获得lock2"); } catch (InterruptedException e) { // 处理中断 } finally { if (lock1.isHeldByCurrentThread()) { lock1.unlock(); } if (lock2.isHeldByCurrentThread()) { lock2.unlock(); } System.out.println(Thread.currentThread().getName() + "End"); } }, "Thread1"); thread1.start(); // 线程2:先获取lock2,再获取lock1 Thread thread2 = new Thread(() -> { try { lock2.lockInterruptibly(); Thread.sleep(4000); lock1.lockInterruptibly(); System.out.println(Thread.currentThread().getName() + "成功获得lock1"); } catch (InterruptedException e) { // 处理中断 } finally { if (lock1.isHeldByCurrentThread()) { lock1.unlock(); } if (lock2.isHeldByCurrentThread()) { lock2.unlock(); } System.out.println(Thread.currentThread().getName() + "End"); } }, "Thread2"); thread2.start(); // 可以利用中断,解决死锁问题 // Thread.sleep(10000); // thread1.interrupt(); } }
运行上述代码会发现,程序一直在运行,无法正常打印结果并结束,这里可以通过中断解决此死锁(当然,通过前面提及的设置等待时间的方法也可以解决死锁问题)。
当然,在分配锁的时候,我们就应当考虑到死锁问题,尽可能的避免死锁的产生。
5. 线程间通讯
synchronized
和 Lock
都有提供相应的线程间通讯机制,具体可以参考: 线程间通讯
这篇关于Java多线程(四):线程安全问题的文章就介绍到这儿,希望我们推荐的文章对大家有所帮助,也希望大家多多支持为之网!
- 2024-11-27消息中间件底层原理资料详解
- 2024-11-27RocketMQ底层原理资料详解:新手入门教程
- 2024-11-27MQ底层原理资料详解:新手入门教程
- 2024-11-27MQ项目开发资料入门教程
- 2024-11-27RocketMQ源码资料详解:新手入门教程
- 2024-11-27本地多文件上传简易教程
- 2024-11-26消息中间件源码剖析教程
- 2024-11-26JAVA语音识别项目资料的收集与应用
- 2024-11-26Java语音识别项目资料:入门级教程与实战指南
- 2024-11-26SpringAI:Java 开发的智能新利器