多线程带来的的风险-线程安全、锁的问题

线程安全的概念

想给出一个线程安全的确切定义是复杂的,但我们可以这样认为:

如果多线程环境下代码运行的结果是符合我们预期的,即在单线程环境应该的结果,则说这个程序是线程安全的。

观察线程不安全

class Counter {
    public int count = 0;

    public void add() {
        count++;
    }
}

public class ThreadDemo13 {
    public static void main(String[] args) {
        Counter counter = new Counter();

        // 搞两个线程, 两个线程分别针对 counter 来 调用 5w 次的 add 方法
        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 50000; i++) {
                counter.add();
            }
        });
        Thread t2 = new Thread(() -> {
            for (int i = 0; i < 50000; i++) {
                counter.add();
            }
        });
        // 启动线程
        t1.start();
        t2.start();

        // 等待两个线程结束
        try {
            t1.join();
            t2.join();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        // 打印最终的 count 值
        System.out.println("count = " + counter.count);
    }
}

        预想的情况是两个线程各自增5w次,运行结束 count 的值应该为10w,但结果每次不一样,因此我们就说出现了 bug。

线程不安全的原因

1.【根本原因】抢占式执行,随即调度

2.代码结构:多个线程同时修改同一个变量。这种可以通过调整代码结构来规避这个问题,但是这种调整不是一种普遍性高的方案(因为代码结构是源于 需求 的,改了之后可能达不到需求或者性价比太低)

3.原子性:如果改写操作是原子性的,那还没啥事;但是是非原子的,出现线程安全问题的概率就非常高了。(原子:不可拆分的基本单位)

像上面的 count++,可以拆分成 load,add,sava 这三个操作。所以 ++ 操作并不是原子性的,因此我们想要解决线程安全问题,就需要把这个操作弄成原子性。也就是通过加锁的操作。这是解决线程安全问题最主要的手段。

synchronized public void add() {
    count++;
}

不保证原子性会给多线程带来什么问题

        如果一个线程正在对一个变量操作,中途其他线程插入进来了,如果这个操作被打断了,结果就可能是错误的。这点也和线程的抢占式调度密切相关. 如果线程不是 "抢占" 的, 就算没有原子性, 也问题不大.

4.内存可见性问题

可见性指, 一个线程对共享变量值的修改,能够及时地被其他线程看到.

Java 内存模型 (JMM): Java虚拟机规范中定义了Java内存模型.

目的是屏蔽掉各种硬件和操作系统的内存访问差异,以实现让Java程序在各种平台下都能达到一致的并发效果.

线程之间的共享变量存在 主内存 (Main Memory).

每一个线程都有自己的 "工作内存" (Working Memory) .

当线程要读取一个共享变量的时候, 会先把变量从主内存拷贝到工作内存, 再从工作内存读取数据.

当线程要修改一个共享变量的时候, 也会先修改工作内存中的副本, 再同步回主内存.

由于每个线程有自己的工作内存, 这些工作内存中的内容相当于同一个共享变量的 "副本". 此时修改线程1 的工作内存中的值, 线程2 的工作内存不一定会及时变化.

1) 初始情况下, 两个线程的工作内存内容一致.

2) 一旦线程1 修改了 a 的值, 此时主内存不一定能及时同步. 对应的线程2 的工作内存的 a 的值也不一定能及时同步.

这个时候代码中就容易出现问题.

此时引入了两个问题:

1) 为啥整这么多内存?

实际并没有这么多 "内存". 这只是 Java 规范中的一个术语, 是属于 "抽象" 的叫法.

所谓的 "主内存" 才是真正硬件角度的 "内存". 而所谓的 "工作内存", 则是指 CPU 的寄存器和高速缓存.

2) 为啥要这么麻烦的拷来拷去?

因为 CPU 访问自身寄存器的速度以及高速缓存的速度, 远远超过访问内存的速度(快了 3 - 4 个数量级, 也就是几千倍, 上万倍).

比如某个代码中要连续 10 次读取某个变量的值, 如果 10 次都从内存读, 速度是很慢的. 但是如果只是第一次从内存读, 读到的结果缓存到 CPU 的某个寄存器中, 那么后 9 次读数据就不必直接访问内存了. 效率就大大提高了.

那么接下来问题又来了, 既然访问寄存器速度这么快, 还要内存干啥?? 因为寄存器贵!

值的一提的是, 快和慢都是相对的. CPU 访问寄存器速度远远快于内存, 但是内存的访问速度又远远快于硬盘.对应的, CPU 的价格最贵, 内存次之, 硬盘最便宜.

5.指令重排序

有一段代码是这样的:

1. 去楼下取下外卖

2. 回房间写 10 分钟作业

3. 去楼下取下快递

        如果是在单线程情况下,JVM、CPU指令集会对其进行优化,比如,按 1 → 3 → 2的方式执行,也是没问题,可以少下一次楼。这种叫做指令重排序。

        编译器对于指令重排序的前提是 "保持逻辑不发生变化",从而提高执行效率. 这一点在单线程环境下比较容易判断, 但是在多线程环境下就没那么容易了, 多线程的代码执行复杂程度更高, 编译器很难在编译阶段对代码的执行效果进行预测, 因此激进的重排序很容易导致优化后的逻辑和之前不等价.

重排序是一个比较复杂的话题, 涉及到 CPU 以及编译器的一些底层工作原理, 此处不做过多讨论

synchronized 关键字 — 监视器锁monitor lock

synchronized 的特性

1) 互斥

synchronized 会起到互斥效果, 某个线程执行到某个对象的 synchronized 中时, 其他线程如果也执行到同一个对象 synchronized 就会阻塞等待.

进入 synchronized 修饰的代码块, 相当于 加锁

退出 synchronized 修饰的代码块, 相当于 解锁

举个例子,加锁就像是上厕所:

如果当前是 "无人" 状态, 那么就可以使用, 使用时需要设为 "有人" 状态.

如果当前是 "有人" 状态, 那么其他人无法使用, 只能排队,理解 "阻塞等待".

注意:

上一个线程解锁之后, 下一个线程并不是立即就能获取到锁. 而是要靠操作系统来 "唤醒". 这也就是操作系统线程调度的一部分工作.

假设有 A B C 三个线程, 线程 A 先获取到锁, 然后 B 尝试获取锁, 然后 C 再尝试获取锁, 此时 B和 C 都在阻塞队列中排队等待. 但是当 A 释放锁之后, 虽然 B 比 C 先来的, 但是 B 不一定就能获取到锁, 而是和 C 重新竞争。

关于锁对象的规则:

        1.如果两个线程针对同一个对象加锁,此时就会出现 锁竞争 / 锁冲突 的问题,其中的一个线程获取到了锁(先到先得),另一个线程只能阻塞等待,等到那个线程解锁了,这个线程才能取锁成功。

       2.如果两个线程针对同一个线程,一个加锁一个不加锁,这个时候就没有 锁竞争 / 锁冲突 的问题,但是却会发生线程安全问题。

       3.如果两个线程针对两个对象加锁,则不会出现 锁竞争 / 锁冲突 的问题,各自获取到各自的锁,不会有阻塞等待。

        

2) 可重入

        一个线程针对同一个对象连续进行两次加锁,是否会出现问题?如果没问题,则是可重入锁;如果有问题,就叫做不可重入锁。

代码示例

    synchronized public void add() {  // 锁对象是 this
        synchronized (this) {
            count++;
        }
    }

        只要有线程调用 add ,进入 add 方法的时候就会进行加锁,紧接着又遇到了代码块。此时站在 this(锁对象)的视角,它认为自己已经被别的线程占用了(上面的代码是个特殊情况:别的线程其实就是这个线程),这里的第二次加锁是否要阻塞等待?如果需要阻塞等待,那么就是不可重入的,这个情况就会导致线程“僵住”,也就是死锁了。

        因为Java 中的 synchronized 是 可重入锁, 因此没有上面的问题。会在锁对象里记录一下,如果当前加锁线程和持有锁的线程是同一个,就会直接通过,不会阻塞。

死锁的四个必要条件:

1.互斥使用:线程 1 拿到了锁,线程 2 就得等着(锁的基本特性)

2.不可抢占:线程 1 拿到锁之后,必须是线程 1 主动释放。不能是线程 2 把锁强行获取到

3.请求和保持:线程 1 拿到 锁A 之后,再尝试获取 锁B ,A 这把锁还是保持的(不会因为获取 B 就把 A 释放了)

4.循环等待:线程 1 尝试获取 A 和 B ,线程 2 尝试获取 B 和 A 。                                                                         线程 1 在获取 B 的时候等到线程 2 释放 B ;同时线程 2 在获取 A 的时候等待线程 1 释放 A 

        前三个条件都是锁的基本特性,改不了;第四点是唯一一个和代码相关的,也是我们可以控制的,因此解决死锁的办法就是给锁编号,按固定的顺序(从小到大)来加锁。

死锁几种常见的情况:

        1.一个线程,一把锁,连续加锁两次,如果锁是不可重入锁,就会出现死锁。

        2.两个线程两把锁,t1 和 t2 各自先针对 锁A 和 锁B 加锁,再尝试获取对方的锁。

public class Main {
    public static void main(String[] args) {
        // 假设 apologize1 是 1 号, apologize2 是 2 号, 约定 1号先道歉, 后 2号道歉.
        Object apologize1 = new Object();
        Object apologize2 = new Object();

        Thread student1 = new Thread(() -> {
            synchronized (apologize1) {

                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }

                synchronized (apologize2) {
                    System.out.println("student1接受了别人的道歉并且自己也道歉了");
                }
            }
        });
        Thread student2 = new Thread(() -> {
            synchronized (apologize2) {

                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }

                synchronized (apologize1) {
                    System.out.println("student2接受了别人的道歉并且自己也道歉了");
                }
            }
        });

        student1.start();
        student2.start();
    }
}

        这个代码的逻辑是:学生 1 做错事了,学生 2 也做错事了,这时候俩人都想等对方先道歉,但两人都怕自己说了但对方反悔不愿意,这时候结果就僵持住了,出现了死锁。

        3.多个线程多把锁(相当于 2 的更进一步)。锁更多,线程更多,情况也就更复杂了。因此根据上述死锁条件的第四点作为突破口就能解决了。

Thread student2 = new Thread(() -> {
            synchronized (apologize1) {

                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }

                synchronized (apologize2) {
                    System.out.println("student2接受了别人的道歉并且自己也道歉了");
                }
            }
        });

        也即先约定好一个先让谁道歉,后一个要紧随其后,问题就解决了。

synchronized 使用示例

1) 直接修饰普通方法

synchronized public void add() {
    count++;
}

2) 修饰静态方法

synchronized public static void add() {
     count++;
}

3) 修饰代码块: 明确指定锁哪个对象.

锁当前对象

public void add() {
// 进入代码块就加锁
    synchronized (this) {  // 这里可以指定任意想指定的对象,不一定非得是 this
        count++;
    }
// 出了代码块就解锁
}

Java 标准库中的线程安全类

        Java 标准库中很多都是线程不安全的. 这些类可能会涉及到多线程修改共享数据, 因此需要我们自己手动加锁

ArrayList、LinkedList、HashMap、TreeMap、HashSet、TreeSet、StringBuilder

但是还有一些是相对线程安全的,已经内置了 synchronized 加锁。使用了一些锁机制来控制.

Vector (不推荐使用)、HashTable (不推荐使用)、ConcurrentHashMap、StringBuffer

还有的虽然没有加锁, 但是不涉及 "修改", 仍然是线程安全的

String

  • 34
    点赞
  • 11
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值