并发安全和死锁
并发安全和死锁
作者:IT王小二
一、并发安全
1. 什么是线程安全性
当多个线程访问某个类时,不管运行时环境采用何种调度方式或者这些线程将如何交替执行,并且在调用代码中不需要任何额外的同步或者协同,这个类都能表现出正确的行为,那么就称这个类是线程安全的 -- Java并发编程实战
那么怎么实现线程安全呢?可以使用下面的这些方式。
2. 线程封闭
1、实现好的并发是一件困难的事情,所以很多时候我们都想躲避并发。避免并发最简单的方法就是线程封闭。什么是线程封闭呢?
就是把对象封装到一个线程里,只有这一个线程能看到此对象。那么这个对象就算不是线程安全的也不会出现任何安全问题
2、实现线程封闭有哪些方法呢?
- ad-hoc 线程封闭:这是完全靠实现者控制的线程封闭,他的线程封闭完全靠实现者实现。Ad-hoc线程封闭非常脆弱,应该尽量避免使用。
- 栈封闭:栈封闭是我们编程当中遇到的最多的线程封闭。什么是栈封闭呢?简单的说就是局部变量,多个线程访问一个方法,此方法中的局部变量都会被拷贝一份到线程栈中,所以局部变量是不被多个线程所共享的,也就不会出现并发问题,所以能用局部变量就别用全局的变量,全局变量容易引起并发问题。
3. 无状态的类
没有任何成员变量的类,就叫无状态的类,这种类一定是线程安全的。
那么下面这个类的方法参数使用了对象,也是线程安全的吗?
public class StatelessClass {
public int service(int a,int b){
return a + b;
}
public void serviceUser(UserVo user){
}
}
当然也是,这是为什么呢?多线程使用的情况下,这个user对象的实例确实会不正常,但是对于StatelessClass这个类来说,它并不持有UserVo的对象实例,它自己本身并不会出问题,有问题的是UserVo这个类,而非StatelessClass本身。
4. 让类不可变
1、加 final 关键字,对于一个类,所有的成员变量应该是私有的,同样的只要有可能,所有的成员变量应该加上 final 关键字,但是加上final,要注意如果成员变量又是一个对象时,这个对象所对应的类也要是不可变,才能保证整个类是不可变的。
2、根本就不提供任何可供修改成员变量的地方,同时成员变量也不作为方法的返回值。
但是需要注:一旦类的成员变量中有对象,上述的 final 关键字保证不可变并不能保证类的安全性,这是为什么呢?因为在多线程下,虽然对象的引用不可变,但是对象在堆上的实例是有可能被多个线程同时修改的,没有正确处理的情况下,对象实例在堆中的数据是不可预知的,例如:
public class ImmutableClass {
private final int a;
private final int b;
/**
* 加上这个就不保证安全了
*/
private final UserVo user = new UserVo();
public int getA() {
return a;
}
public UserVo getUser() {
return user;
}
public ImmutableClass(int a, int b) {
this.a = a;
this.b = b;
}
public static class User {
private int age;
public int getAge() {
return age;
}
public void setAge(int age) {
this.age = age;
}
}
}
5. volatile
volatile可以说是java虚拟机提供的最轻量级的同步机制,但是并不能保证类的线程安全性,只能保证类的可见性,最适合一个线程写,多个线程读的情景。
6. 加锁和CAS
我们最常使用的保证线程安全的手段,使用 synchronized 关键字,使用显式锁,使用各种原子变量,修改数据时使用 CAS 机制等等。
7. 安全的发布
类中持有的成员变量,如果是基本类型,发布出去,并没有关系,因为发布出去的其实是这个变量的一个副本。
但是如果类中持有的成员变量是对象的引用,如果这个成员对象不是线程安全的,通过 get 等方法发布出去,会造成这个成员对象本身持有的数据在多线程下不正确的修改,从而造成整个类线程不安全的问题。
那么怎么保证发布对象多线程下的安全呢?我们可以参考Collections.synchronizedList
方法来实现我们自己发布对象的安全性,查看源码可以知道是通过synchronized
关键字来实现的:
private List<Integer> list = Collections.synchronizedList(new ArrayList<>(3));
接下来我们就模仿一下发布一个安全的类。
public final class FinalUserVo {
private int age;
public int getAge() {
return age;
}
public void setAge(int age) {
this.age = age;
}
}
public class SafePublicFinalUser {
private final SynFinalUser user;
public SynFinalUser getUser() {
return user;
}
public SafePublicFinalUser(FinalUserVo user) {
this.user = new SynFinalUser(user);
}
public static class SynFinalUser {
private final FinalUserVo userVo;
private final Object lock = new Object();
public SynFinalUser(FinalUserVo userVo) {
this.userVo = userVo;
}
public int getAge() {
synchronized (lock) {
return userVo.getAge();
}
}
public void setAge(int age) {
synchronized (lock) {
userVo.setAge(age);
}
}
}
}
8. TheadLocal
ThreadLocal 是实现线程封闭的最好方法。ThreadLocal 内部维护了一个 Map,Map 的 key 是每个线程的名称,而 Map 的值就是我们要封闭的对象。每个线程中的对象都对应着 Map 中一个值,也就是 ThreadLocal 利用 Map 实现了对象的线程封闭。
其实本质还是可以认为是将ThreadLocal封闭的对象,将副本存储在了每个线程中,还是每个线程的局部变量,互不影响。
9. Servlet 辨析
Servlet不是一个线程安全的类,但是为什么我们用Servlet的时候没有感觉到呢,因为在需求上每个Servlet中很少有共享需求,接收到了请求,处理时都是一个单独的线程来负责。
但是如果 Servlet 中有成员变量,一旦有多线程下的写,就很容易产生线程安全问题。
三、死锁
1. 概念
死锁是指两个或两个以上的进程在执行过程中,由于竞争资源或者由于彼此通信而造成的一种阻塞的现象,若无外力作用,它们都将无法推进下去。此时称系统处于死锁状态或系统产生了死锁,这些永远在互相等待的进程称为死锁进程 -- 百度百科
你没看错,死锁并不止于线程,说进程也没毛病,因为,死锁往往是各进程之间的线程相互抢夺资源导致的。
总结下来就是:死锁是必然发生在多操作者(M>=2 个)情况下,争夺多个资源(N>=2 个, 且 N<=M)才会发生这种情况。
2. 死锁现象
简单顺序死锁
由于锁顺序定义不一致导致死锁。
示例:
public class SimpleDeadLock {
/**
* 第一个锁
*/
private static Object valueFirst = new Object();
/**
* 第二个锁
*/
private static Object valueSecond = new Object();
/**
* 先拿第一个锁,再拿第二个锁
*/
private static void fisrtToSecond() throws InterruptedException {
String threadName = Thread.currentThread().getName();
synchronized (valueFirst) {
System.out.println(threadName + " 拿到了第一把锁");
Thread.sleep(100);
synchronized (valueSecond) {
System.out.println(threadName + " 拿到了第二把锁");
}
}
}
/**
* 先拿第二个锁,再拿第一个锁
*/
private static void SecondToFisrt() throws InterruptedException {
String threadName = Thread.currentThread().getName();
synchronized (valueSecond) {
System.out.println(threadName + " 拿到了第二把锁");
Thread.sleep(100);
synchronized (valueFirst) {
System.out.println(threadName + " 拿到了第一把锁");
}
}
}
private static class TestThread extends Thread {
private String name;
public TestThread(String name) {
this.name = name;
}
@Override
public void run() {
Thread.currentThread().setName(name);
try {
SecondToFisrt();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
public static void main(String[] args) {
Thread.currentThread().setName("TestDeadLock");
TestThread testThread = new TestThread("SubTestThread");
testThread.start();
try {
fisrtToSecond();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
这种简单的顺序死锁看到代码一眼就可以看出来,解决办法也很简单,只要把锁的顺序保持一致就行了。
动态顺序死锁
顾名思义也是和获取锁的顺序有关,但是比较隐蔽,不像简单顺序死锁,往往从代码一眼就看出获取锁的顺序不对。
比如:两个银行账户之间在同一时间相互转账,下面仅为转账动作代码。
/**
* 转账动作代码,省略判断银行账户剩余金额
* @param from 转出银行账户
* @param to 转入银行账户
* @param amount 转账金额
* @throws InterruptedException 中断异常
*/
public void transfer(UserAccount from, UserAccount to, int amount)
throws InterruptedException {
// 锁定from账号
synchronized (from) {
System.out.println(Thread.currentThread().getName() + " get" + from.getName());
Thread.sleep(100);
// 锁定to账号
synchronized (to) {
System.out.println(Thread.currentThread().getName() + " get" + to.getName());
// from转出金额
from.flyMoney(amount);
// to转入金额
to.addMoney(amount);
}
}
}
线程A从X账户向Y账户转账,线程B从账户Y向账户X转账,这时候问题就产生了,两线程想要获取转入账户的锁,但是分别被其他线程持有,这时候死锁就产生了。
那么怎么解决呢?
有两种解决方案(思路仅供参考),当然不排除大佬们有更好的解决方案。
- 第一种,使用hash值来确定执行顺序,如果hash值相等的话就用第三把锁来确定持有锁的顺序。
- 第二种,使用显式锁,在 UserAccount 中定义 Lock,然后在转账方法中使用 tryLock() 尝试拿 form的锁,如果拿到了那么就再次尝试拿 to的锁,如果全部拿到了那么自然执行转账,如果没有拿到则释放自己拿到的锁。
3. 如果实际中出现了死锁怎么解决
死锁的危害
- 线程不工作了,但是整个程序还是活着的 。
- 没有任何的异常信息可以供我们检查。
- 一旦程序发生了发生了死锁,是没有任何的办法恢复的,只能重启程序,对生产平台的程序来说,这是个很严重的问题。
实际工作中的死锁
在实际工作中,如果测试没有并发测试,大多数情况下可能死锁无法复现,那如果真的出现了死锁要怎么排查解决呢?
解决办法
以上述的简单顺序死锁案例为例
1、首先使用 jps
命令来查询应用id,然后通过 jstack id
来 定位 锁的持有情况。
2、定位到了问题了,那么就要 修正 了,通常都是保证拿锁的顺序一致,通常两种解决方案。
- 通过内部比较,确定拿锁的顺序。
- 采用尝试拿锁的机制。
4. 其他的概念
活锁
两个线程在尝试拿锁的机制中,发生多个线程之间互相谦让,不断发生同一个线程总是拿到同一把锁,在尝试拿另一把锁时因为拿不到,而将本来已经持有的锁释放的过程(例如上面修正方案中说的尝试拿锁机制)。
解决办法:每个线程休眠随机数,错开拿锁的时间。
线程饥饿
低优先级的线程,总是拿不到执行时间。
5. 线程安全的单例模式
饿汉模式
在声明的时候就 new 这个类的实例,因为在 JVM 中,对类的加载和类初始化,由虚拟机保证线程安全。
懒汉模式
饿汉模式下双重检查锁定能够保证安全吗?假设代码如下:
public class SingleHungry {
private static SingleHungry singleHungry;
private UserVo userVo;
// 私有化
private SingleHungry(){
}
public static SingleHungry getInstance(){
if (singleHungry == null){
// 第一次检查,不加锁
synchronized(SingleHungry.class){
// 加锁
if (singleHungry == null){
// 第二次检查,加锁情况下
singleHungry = new SingleHungry();
}
}
}
return singleHungry;
}
}
咋一看上去,没什么毛病,稳得很,我以前也一直都这么用过来的,后来看了JVM后相关知识发现并不对。。。
为什么不对呢?
就我目前的认知来说,可以从 jvm对象的创建过程 和 重排序 的两个层面来说明这个问题。
- 我就从jvm对象的创建过程来说明这个问题,两个线程同时来获取这个单例的实例时,比如A线程已经执行到了new SingleHungry() 。
- 对象创建过程包括:检查加载 -> 分配内存 -> 内存空间初始化 -> 设置 -> 对象的初始化。
- 当执行new关键字的时候首先给这个对象执行了
检查加载 -> 分配内存 -> 内存空间初始化 -> 设置
,此时,singleHungry 已经有了指向引用不为null了,但是他的属性(比如示例中的user)此时还未初始化,还是为null 。 - 这时,B线程执行 getInstance() 方法后发现实例不为空了,然后获取到了 singleHungry 使用,然后 singleHungry.getUser().getName(),发现报 NullPointException 了。
解决办法:
- 使用饿汉模式。
- 或者懒汉模式的 singleHungry 加上
volatile
关键字。