线程安全
概述
线程安全定义
当多个线程同时共享,同一个全局变量或静态变量,做写的操作时,可能会发生数据冲突问题,也就是线程安全问题.但是做读操作是不会发生数据冲突问题
线程安全性问题出现条件
- 多线程环境下
- 多线程共享一个资源
- 对资源进行非原子性操作
案例
1 | public class ThreadTrain implements Runnable { |
运行结果:
1 | 1号,出售第1张票 |
解决办法
使用多线程之间同步synchronized或使用锁(lock).将可能会发生数据冲突问题(线程不安全问题),只能让当前一个线程进行执行.代码执行完成后释放锁,然后才能让其他线程进行执行.这样的话就可以解决线程不安全问题.当多个线程共享同一个资源,不会受到其他线程的干扰
synchronized
概述
- Java提供了一种内置的锁机制来支持原子性
- 每一个Java对象都可以用作一个实现同步的锁,称为内置锁,线程进入同步代码块之前自动获取到锁,代码块执行完成正常退出或代码块中抛出异常退出时会释放掉锁
- 内置锁为互斥锁,即线程A获取到锁后,线程B阻塞直到线程A释放锁,线程B才能获取到同一个锁
- 内置锁使用synchronized关键字实现,synchronized关键字有两种用法:
- 修饰需要进行同步的方法(所有访问状态变量的方法都必须进行同步),此时充当锁的对象为调用同步方法的对象
- 同步代码块和直接使用synchronized修饰需要同步的方法是一样的,但是锁的粒度可以更细,并且充当锁的对象不一定是this,也可以是其它对象,所以使用起来更加灵活
同步代码块
就是将可能会发生线程安全问题的代码,给包括起来.格式如下:
1 | synchronized(对象) { //这个对象可以为任意对象 |
对象如同锁,持有锁的线程可以在同步中执行,没持有锁的线程即使获取CPU的执行权,也进不去
同步前提:
- 必须要有两个或两个以上的线程
- 必须是多个线程使用同一个锁
代码样例–将上面的sale()方法加锁
1 | public void sale() { |
同步方法
在方法上修饰synchronized称为同步方法
代码样例:
1 | public synchronized void sale() { |
- 同步函数使用this锁.注意这里是非静态的普通方法
- 证明方式: 一个线程使用同步代码块(this明锁),另一个线程使用同步函数.如果两个线程抢票不能实现同步,那么会出现数据错误
静态同步函数
- synchronized关键字修饰静态方法
- 静态的同步函数使用的锁是 该函数所属字节码文件对象
- 可以用getClass方法获取,也可以用当前 类名.class表示
代码样例:
1 | public static synchronized void sale() { |
线程安全–死锁
哲学家就餐问题:五个哲学家在吃饭的时候分别分给一支筷子,当它们在谈论哲学问题的时候有的哲学家已经饿了,于是借用旁边人的筷子组成一双筷子去吃饭,这种情况下是正常的;但我们的程序设计成每个人都不聊哲学问题,都在吃饭,于是每个人都拿起了自己手中的筷子,在等待另外人放下筷子,结果每个人都不放都饿死了;这就是所谓的死锁问题
即另外人手中有自己所需要的资源,自己手中也有另外人所需的资源,但俩人都不释放,所以俩人都拿不到,这就是死锁问题
死锁如何排查
死锁代码示例
直接看下面死锁代码,这是一个典型的死锁,线程1拿到A锁获取B锁,线程2拿到B锁获取A锁
1 | public class DeadLockDemo { |
我们编辑上述代码,结果如下:
实际上线程1永远获取不到B锁,线程2永远获取不到A锁,问题如何排查解决呢
方式一:通过jps+jstack命令
我们可以通过jps命令获取当前进程的id
id为6988的进程即使刚刚产生死锁的程序,我们记住这个id.使用jstack命令去查看该线程的dump日志信息
1 | jstack -l pid |
结果如下图
可以看到标红的信息,此dump文件告诉了我们死锁发生的位置,我们就可以跟进代码继续去排查程序中的问题
方式二:通过jconsole
在windons命令窗口,输出JConsole,选择本地进程,选择DeadLockTest点下面的连接
点线程,点击检测死锁
同样可以定位到死锁信息,以及死锁发生的位置,如下图
方式三:使用jvisualvm
选择线程
点进去我们看下信息,会发现同样定位到了死锁相关信息,以及死锁发生的位置
死锁的原因
Java发生死锁的根本原因是:在申请锁时发生了交叉闭环申请.即线程在获得了锁A并且没有释放的情况下去申请锁B,这时另一个线程已经获得了锁B,在释放锁B之前又要先获得锁A,因此闭环发生陷入死锁循环
死锁的解决方法
- 调整申请锁的范围
比如原来锁是加在方法上的,现在改在方法内的一部分,这样在使用第二个锁时本身的锁已经释放了.如果减小锁的申请范围可以避免锁的申请发生闭环的话,那么就可以避免死锁 - 调整申请锁的顺序
在有些情况下是不允许我们调整锁的范围的,比如银行转账的场景下,我们必须同时获得两个账户上的锁,才能进行操作,两个锁的申请必须发生交叉.这时要想打破死锁闭环,必须调整锁的申请顺序,总是以相同的顺序来申请锁,比如总是先申请id大的账户上的锁,然后再申请id小的账户上的锁,这样就无法形成导致死锁的那个闭环这样的话,即使发生了两个账户比如id=1的和id=100的两个账户相互转账,因为不管是哪个线程先获得了id=100上的锁,另外一个线程都不会去获得id=1上的锁(因为他没有获得id=100上的锁),只能是哪个线程先获得id=100上的锁,哪个线程就先进行转账.这里除了使用id之外,如果没有类似id这样的属性可以比较,那么也可以使用对象的hashCode()的值来进行比较1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25public class Account {
private int id; // 主键
private String name;
private double balance;
public void transfer(Account from, Account to, double money){
if(from.getId() > to.getId()){
synchronized(from){
synchronized(to){
// transfer
}
}
}else{
synchronized(to){
synchronized(from){
// transfer
}
}
}
}
public int getId() {
return id;
}
}
避免死锁的发生
很多时候实际锁的交叉可能涉及很多个,要想很好的避免只能人工仔细检查,一旦我们在一个同步方法中,或者说在一个锁的保护的范围中,调用了其它对象的方法时,就要十分的小心:
- 如果其它对象的这个方法会消耗比较长的时间,那么就会导致锁被我们持有了很长的时间
- 如果其它对象的这个方法是一个同步方法,那么就要注意避免发生死锁的可能性了;
线程安全–饥饿
餐厅排队吃饭,只有一个窗口,但是排队的人非常没有素质,来了就插队硬挤,而且买到了饭还不走,所以可能就有一个弱小的女生死活就进不进去,于是就被饿死了;这就是饥饿
在线程中就是有优先级,有的线程优先级高,有的线程优先级低,可能就一直得不到,就是饥饿的一种(高优先级吞噬所有低优先级的CPU时间片);
饥饿与公平:
- 高优先级吞噬所有低优先级的CPU时间片
- 线程被永久堵塞在一个等待进入同步块的状态
- 等待的线程永远不被唤醒
如何尽量避免饥饿问题:
- 设置合理的优先级
- 使用锁来代替synchronized
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25public class Target implements Runnable {
public void run() {
while (true) {
System.out.println(Thread.currentThread().getName()+" ...");
//Thread.sleep();
}
}
}
public class Demo {
public static void main(String[] args) {
Thread thread1 = new Thread(new Target());
Thread thread2 = new Thread(new Target());
Thread thread3 = new Thread(new Target());
Thread thread4 = new Thread(new Target());
thread1.setPriority(Thread.MAX_PRIORITY);
thread2.setPriority(Thread.MIN_PRIORITY);
//thread1.setPriority(10);
//thread1.setPriority(10);
thread1.start();
thread2.start();
}
}线程安全–活锁
就是两个人非常有礼貌,有两条独木桥,两个人走着走着相遇了,俩人握了个手,都退出了这座桥,都选择了另外一座桥,然后俩人又遇上了,又不好意思,又俩人都换桥,就这么反反复复,这就是活锁问题线程安全与内存模型的关系