目录
  1. 1. 线程安全
    1. 1.1. 概述
      1. 1.1.1. 线程安全定义
      2. 1.1.2. 线程安全性问题出现条件
      3. 1.1.3. 案例
      4. 1.1.4. 解决办法
    2. 1.2. synchronized
      1. 1.2.1. 概述
      2. 1.2.2. 同步代码块
      3. 1.2.3. 同步方法
      4. 1.2.4. 静态同步函数
    3. 1.3. 线程安全–死锁
      1. 1.3.1. 死锁如何排查
        1. 1.3.1.1. 死锁代码示例
        2. 1.3.1.2. 方式一:通过jps+jstack命令
        3. 1.3.1.3. 方式二:通过jconsole
        4. 1.3.1.4. 方式三:使用jvisualvm
      2. 1.3.2. 死锁的原因
      3. 1.3.3. 死锁的解决方法
      4. 1.3.4. 避免死锁的发生
    4. 1.4. 线程安全–饥饿
    5. 1.5. 线程安全–活锁
    6. 1.6. 线程安全与内存模型的关系
线程安全

线程安全

概述

线程安全定义

当多个线程同时共享,同一个全局变量或静态变量,做写的操作时,可能会发生数据冲突问题,也就是线程安全问题.但是做读操作是不会发生数据冲突问题

线程安全性问题出现条件

  • 多线程环境下
  • 多线程共享一个资源
  • 对资源进行非原子性操作

案例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
public class ThreadTrain implements Runnable {
private int trainCount = 100;

@Override
public void run() {
while (trainCount > 0) {
try {
Thread.sleep(50);
} catch (InterruptedException e) {

}
sale();
}
}

private void sale() {
if (trainCount > 0) {
System.out.println(Thread.currentThread().getName()+ ",出售第" + (100 - trainCount + 1) + "张票");
trainCount --;
}
}

public static void main(String[] args) {
ThreadTrain threadTrain = new ThreadTrain();
Thread t1 = new Thread(threadTrain, "1号");
Thread t2 = new Thread(threadTrain, "2号");

t1.start();
t2.start();
}
}

运行结果:

1
2
3
4
5
6
7
8
9
10
11
12
1号,出售第1张票
2号,出售第1张票
1号,出售第3张票
2号,出售第3张票
1号,出售第5张票
2号,出售第5张票
1号,出售第7张票
2号,出售第7张票
1号,出售第9张票
2号,出售第9张票
2号,出售第11张票
1号,出售第11张票

解决办法

  使用多线程之间同步synchronized或使用锁(lock).将可能会发生数据冲突问题(线程不安全问题),只能让当前一个线程进行执行.代码执行完成后释放锁,然后才能让其他线程进行执行.这样的话就可以解决线程不安全问题.当多个线程共享同一个资源,不会受到其他线程的干扰

synchronized

概述

  • Java提供了一种内置的锁机制来支持原子性
  • 每一个Java对象都可以用作一个实现同步的锁,称为内置锁,线程进入同步代码块之前自动获取到锁,代码块执行完成正常退出或代码块中抛出异常退出时会释放掉锁
  • 内置锁为互斥锁,即线程A获取到锁后,线程B阻塞直到线程A释放锁,线程B才能获取到同一个锁
  • 内置锁使用synchronized关键字实现,synchronized关键字有两种用法:
    • 修饰需要进行同步的方法(所有访问状态变量的方法都必须进行同步),此时充当锁的对象为调用同步方法的对象
    • 同步代码块和直接使用synchronized修饰需要同步的方法是一样的,但是锁的粒度可以更细,并且充当锁的对象不一定是this,也可以是其它对象,所以使用起来更加灵活

同步代码块

就是将可能会发生线程安全问题的代码,给包括起来.格式如下:

1
2
3
synchronized(对象) { //这个对象可以为任意对象
需要被同步的代码
}

对象如同锁,持有锁的线程可以在同步中执行,没持有锁的线程即使获取CPU的执行权,也进不去
同步前提:

  1. 必须要有两个或两个以上的线程
  2. 必须是多个线程使用同一个锁

代码样例–将上面的sale()方法加锁

1
2
3
4
5
6
7
8
public void sale() {
synchronized (this) {
if (trainCount > 0) {
System.out.println(Thread.currentThread().getName() + ",出售第" + (100 - trainCount + 1) + "张票");
trainCount--;
}
}
}

同步方法

在方法上修饰synchronized称为同步方法
代码样例:

1
2
3
4
5
6
public synchronized void sale() {
if (trainCount > 0) {
System.out.println(Thread.currentThread().getName() + ",出售第" + (100 - trainCount + 1) + "张票");
trainCount--;
}
}
  • 同步函数使用this锁.注意这里是非静态的普通方法
  • 证明方式: 一个线程使用同步代码块(this明锁),另一个线程使用同步函数.如果两个线程抢票不能实现同步,那么会出现数据错误

静态同步函数

  • synchronized关键字修饰静态方法
  • 静态的同步函数使用的锁是 该函数所属字节码文件对象
  • 可以用getClass方法获取,也可以用当前 类名.class表示

代码样例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public static synchronized  void sale() {
if (trainCount > 0) {
System.out.println(Thread.currentThread().getName() + ",出售第" + (100 - trainCount + 1) + "张票");
trainCount--;
}
}

//上面的就等同于如下代码块,锁对象为当前类的字节码文件对象

public static void sale() {
synchronized (ThreadTrain.class) {
if (trainCount > 0) {
System.out.println(Thread.currentThread().getName() + ",出售第" + (100 - trainCount + 1) + "张票");
trainCount--;
}
}
}

线程安全–死锁

  哲学家就餐问题:五个哲学家在吃饭的时候分别分给一支筷子,当它们在谈论哲学问题的时候有的哲学家已经饿了,于是借用旁边人的筷子组成一双筷子去吃饭,这种情况下是正常的;但我们的程序设计成每个人都不聊哲学问题,都在吃饭,于是每个人都拿起了自己手中的筷子,在等待另外人放下筷子,结果每个人都不放都饿死了;这就是所谓的死锁问题
  即另外人手中有自己所需要的资源,自己手中也有另外人所需的资源,但俩人都不释放,所以俩人都拿不到,这就是死锁问题

死锁如何排查

死锁代码示例

直接看下面死锁代码,这是一个典型的死锁,线程1拿到A锁获取B锁,线程2拿到B锁获取A锁

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
public class DeadLockDemo {
private static Object A = new Object(), B = new Object();

public static void main(String[] args) {
new Thread(() -> {
System.out.println("线程1开始执行...");

synchronized (A) {
try {
System.out.println("线程1拿到A锁");
//休眠两秒让线程2有时间拿到B锁
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}

synchronized (B) {
System.out.println("线程1拿到B锁");
}
}).start();

new Thread(() -> {
System.out.println("线程2开始执行...");
synchronized (B) {
try {
System.out.println("线程2拿到B锁");
//休眠两秒让线程1有时间拿到A锁
Thread.sleep(2000);
} catch (Exception e) {
e.printStackTrace();
}
synchronized (A) {
System.out.println("线程2拿到A锁");
}
}
}).start();
}
}

我们编辑上述代码,结果如下:
死锁运行结果.png
实际上线程1永远获取不到B锁,线程2永远获取不到A锁,问题如何排查解决呢

方式一:通过jps+jstack命令

我们可以通过jps命令获取当前进程的id
死锁jps.png
id为6988的进程即使刚刚产生死锁的程序,我们记住这个id.使用jstack命令去查看该线程的dump日志信息

1
jstack -l pid

结果如下图
死锁jtack.png
可以看到标红的信息,此dump文件告诉了我们死锁发生的位置,我们就可以跟进代码继续去排查程序中的问题

方式二:通过jconsole

在windons命令窗口,输出JConsole,选择本地进程,选择DeadLockTest点下面的连接
死锁jconsole.png
点线程,点击检测死锁
死锁jconsole检测.png
同样可以定位到死锁信息,以及死锁发生的位置,如下图
死锁jconsole结果.png

方式三:使用jvisualvm

选择线程
死锁jvisualvm.png
点进去我们看下信息,会发现同样定位到了死锁相关信息,以及死锁发生的位置
死锁jvisualvm结果.png

死锁的原因

  Java发生死锁的根本原因是:在申请锁时发生了交叉闭环申请.即线程在获得了锁A并且没有释放的情况下去申请锁B,这时另一个线程已经获得了锁B,在释放锁B之前又要先获得锁A,因此闭环发生陷入死锁循环

死锁的解决方法

  1. 调整申请锁的范围
    比如原来锁是加在方法上的,现在改在方法内的一部分,这样在使用第二个锁时本身的锁已经释放了.如果减小锁的申请范围可以避免锁的申请发生闭环的话,那么就可以避免死锁
  2. 调整申请锁的顺序
    在有些情况下是不允许我们调整锁的范围的,比如银行转账的场景下,我们必须同时获得两个账户上的锁,才能进行操作,两个锁的申请必须发生交叉.这时要想打破死锁闭环,必须调整锁的申请顺序,总是以相同的顺序来申请锁,比如总是先申请id大的账户上的锁,然后再申请id小的账户上的锁,这样就无法形成导致死锁的那个闭环
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    public 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;
    }
    }
    这样的话,即使发生了两个账户比如id=1的和id=100的两个账户相互转账,因为不管是哪个线程先获得了id=100上的锁,另外一个线程都不会去获得id=1上的锁(因为他没有获得id=100上的锁),只能是哪个线程先获得id=100上的锁,哪个线程就先进行转账.这里除了使用id之外,如果没有类似id这样的属性可以比较,那么也可以使用对象的hashCode()的值来进行比较

避免死锁的发生

很多时候实际锁的交叉可能涉及很多个,要想很好的避免只能人工仔细检查,一旦我们在一个同步方法中,或者说在一个锁的保护的范围中,调用了其它对象的方法时,就要十分的小心:

  1. 如果其它对象的这个方法会消耗比较长的时间,那么就会导致锁被我们持有了很长的时间
  2. 如果其它对象的这个方法是一个同步方法,那么就要注意避免发生死锁的可能性了;

线程安全–饥饿

  餐厅排队吃饭,只有一个窗口,但是排队的人非常没有素质,来了就插队硬挤,而且买到了饭还不走,所以可能就有一个弱小的女生死活就进不进去,于是就被饿死了;这就是饥饿
  在线程中就是有优先级,有的线程优先级高,有的线程优先级低,可能就一直得不到,就是饥饿的一种(高优先级吞噬所有低优先级的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
    25
    public class Target implements Runnable {
    @Override
    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();
    }
    }

    线程安全–活锁

    就是两个人非常有礼貌,有两条独木桥,两个人走着走着相遇了,俩人握了个手,都退出了这座桥,都选择了另外一座桥,然后俩人又遇上了,又不好意思,又俩人都换桥,就这么反反复复,这就是活锁问题

    线程安全与内存模型的关系

文章作者: Eric Liang
文章链接: https://ericql.github.io/2019/11/12/02-Java%E5%B9%B6%E5%8F%91%E7%BC%96%E7%A8%8B/01-%E5%BA%94%E7%94%A8%E7%AF%87/03-%E7%BA%BF%E7%A8%8B%E5%AE%89%E5%85%A8/
版权声明: 本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议。转载请注明来自 Eric Liang
打赏
  • 微信
  • 支付宝