Java Web 实战 05 - 多线程基础之 synchronized 关键字以及 volatile 关键字

本篇文章 , 会给大家继续讲解多线程当中的 synchronized 关键字以及 volatile 关键字 , synchronized 能够保证内存的原子性 , 我们需要了解这个特性 . 然后讲解了 volatile 关键字 , volatile 能保证内存可见性 , 但是 volatile 不保证原子性 , 然后 volatile 也可以禁止指令重排序 , 在这一篇文章大家都可以了解到
推荐大家跳转语雀页面进行观看 , 点击链接即可跳转
上一篇文章也给大家贴在这里了上一篇文章点击即可跳转
各位看官慢慢观看~
在这里插入图片描述

五 . synchronized 关键字

我们刚才刚刚分析过 : 我们可以将不是原子操作的代码通过 synchronized关键字打包成一个原子的操作
比如 : 我们去 ATM 取钱
image.png
加锁有很多方式 , 我们常见的就是 synchronized以及 ReentrantLock
我们先来看 synchronized 关键字

5.1 synchronized 能保证内存原子性

此处的 synchronized 从字面上翻译 , 叫做 “同步” , synchronized 这里的 “同步” 实际上指的是 “互斥”

互斥 , 就和谈恋爱一样的
通常情况下 , 一个人同一时刻只能谈一个对象
我和哥哥谈恋爱了 , 那我就不能让别的女孩子接近哥哥

“同步” , 还有其他的意思
同步和异步 , 一般出现在 IO 的场景 , 或者是上下级调用的场景
举个栗子 :
我去餐馆吃饭
同步 : 发起请求之后 , 就在取餐口盯着 , 饭做好之后自己就端走了 (类似麻辣烫)
调用者自己来负责获取到调用结果
异步 : 发起请求之后 , 我就不管了 , 找一个座位等着上菜了 , 菜好了服务员会端过来 (类似烧烤)
调用者自己不负责获取调用结果 , 由被调用者把算好的结果主动推送过来

我们之前写过的两个线程并发执行自增操作的代码 , 明显是线程不安全的

// 创建两个线程,让这两个线程同时并发的对一个变量自增 5w 次
// 最终预期能够一共自增 10w 次
class Counter {
    // 用来保存计数的变量
    public int count;

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

public class Demo14 {
    // 这个实例的作用是为了进行累加
    public static Counter counter = new Counter();

    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 50000; i++) {
                counter.increase();
            }
        });

        Thread t2 = new Thread(() -> {
            for (int i = 0; i < 50000; i++) {
                counter.increase();
            }
        });

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

        t1.join();
        t2.join();

        System.out.println("count:" + counter.count);
    }
}

image.png
那怎样解决呢 ?
我们在自增操作的方法前面加上 synchronized
image.png
那为什么加了 synchronized之后 , 结果就正确了呢 ?
image.png
由于加锁操作 , 产生了阻塞等待 , 强行把后面的 LOAD ADD SAVE 与前面的岔开了 , 这样就能够保证一个线程 SAVE 之后 , 另一个线程才 LOAD , 这样的话计算结果就精确了

5.2 synchronized 使用示例

5.2.1 直接修饰普通方法

我们刚才也介绍过了 , 直接在方法前面加上 synchronized关键字即可
image.png

5.2.2 修饰静态方法

与修饰普通方法一样 , 在前面加上 synchronized关键字即可
image.png

5.2.3 修饰代码块

// 创建两个线程,让这两个线程同时并发的对一个变量自增 5w 次
// 最终预期能够一共自增 10w 次
class Counter {
    // 用来保存计数的变量
    public int count;

    public void increase() {
        synchronized (this) {
            count++;
        }
    }
}

public class Demo14 {
    // 这个实例的作用是为了进行累加
    public static Counter counter = new Counter();

    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 50000; i++) {
                counter.increase();
            }
        });

        Thread t2 = new Thread(() -> {
            for (int i = 0; i < 50000; i++) {
                counter.increase();
            }
        });

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

        t1.join();
        t2.join();

        System.out.println("count:" + counter.count);
    }
}

image.png

在我们的代码中 , 锁对象就是 counter 对象
image.png
修饰普通方法和修饰静态方法的锁对象还不太相同
202312992748.bmp
无论是使用哪种用法 , 使用 synchronized 的时候 , 都要明确锁对象 (明确给哪个对象进行加锁)
只有当两个线程针对同一个对象加锁的时候 , 才会发生竞争
如果是两个线程针对不同对象加锁 , 则不产生竞争

两个男生追同一个女生 : 发生竞争
两个男生一个追男生 , 一个追女生 : 不发生竞争

5.3 Java 标准库中的线程安全类

在 Java 标准库中 , 大部分的集合类 , 都是线程不安全的

  • ArrayList
  • LinkedList
  • HashMap
  • TreeMap
  • HashSet
  • TreeSet
  • StringBuilder

当然也有少部分集合类 , 是线程安全的

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

其中 , Vector 和 HashTable 是不推荐使用的 , 这两个是上古时期的集合类了 , 他们俩把所有的关键方法都无脑加了 synchronized, 加锁的代价 , 会牺牲很大的运行速度

加锁成功 , 就容易产生阻塞等待
t1 加锁成功 , t2 尝试加锁 , 就会进入阻塞等待的状态 (BLOCK 状态)
这样 t1 t2 就不能进行并发
当 t1 解锁之后 , t2 也不一定就立马获取到锁 . 这还是需要看操作系统的具体调度的

ConcurrentHashMap 是线程安全的哈希表 , 内部做了一系列的优化手段 , 来提高效率
StringBuffer 也是线程安全的 , 内部使用了 synchronized关键字
image.png
String 也是线程安全的 , 但是它的实现逻辑并不是加锁 , 而是设置为不可修改

5.4 代码案例 : 针对不同对象进行加锁

这个代码是两个线程不发生锁竞争的代码

public class Demo15 {
    // 在上面先把锁对象创建出来
    public static Object locker1 = new Object();
    public static Object locker2 = new Object();

    public static void main(String[] args) {
        // 创建两个线程,让他们针对不同对象去加锁
        Thread t1 = new Thread(() -> {
            synchronized (locker1) {
                System.out.println("t1 开始");
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                System.out.println("t1 结束");
            }
        });
        t1.start();

        Thread t2 = new Thread(() -> {
            synchronized (locker2) {
                System.out.println("t2 开始");
                try {
                    Thread.sleep(500);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                System.out.println("t2 结束");
            }
        });
        t2.start();
    }
}

image.png
那我们要把线程 t2 里面的锁对象改成 locker1 , 就一定会发生阻塞等待现象

public class Demo15 {
    // 在上面先把锁对象创建出来
    public static Object locker1 = new Object();
    public static Object locker2 = new Object();

    public static void main(String[] args) {
        // 创建两个线程,让他们针对不同对象去加锁
        Thread t1 = new Thread(() -> {
            synchronized (locker1) {
                System.out.println("t1 开始");
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                System.out.println("t1 结束");
            }
        });
        t1.start();

        Thread t2 = new Thread(() -> {
            synchronized (locker1) {
                System.out.println("t2 开始");
                try {
                    Thread.sleep(500);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                System.out.println("t2 结束");
            }
        });
        t2.start();
    }
}

image.png
加上同一把锁 , 就只能 t1 执行完之后 , 释放了锁 , 然后 t2 才能进行 start , 这就发生了锁竞争

另外 , 我们这里先写的 t1.start , 后写的 t2.start , 就一定是 t1 先执行吗 ?
不一定是 t1 先执行 , t2 后执行 !
start 操作是在系统内核里创建出线程 , 也就是构造 PCB , 然后加入到链表中
具体线程的入口方法执行 , 还是要看系统的调度 (t1 t2 执行先后不一定)


使用锁 , 一定程度上解决了线程安全问题
但是加锁也不是万能的 , 也需要看锁加的位置对不对
一个线程加了锁 , 另一个线程不加锁 , 也修改同一个变量 , 他也是线程不安全的

比如 : 刘浩存和易烊千玺处对象 , 他们发微博官宣了 , 但是王源还是对刘浩存死缠烂打

使用锁 , 是保证了线程安全的原子性 , 将非原子操作打包成原子操作
但是线程安全问题 , 还有内存可见性问题 , 这怎么解决 ?

六 . volatile 关键字

内存可见性 , 针对的场景是一个写 , 一个进行读

import java.util.Scanner;

public class Demo16 {
    // 我们刚才在别的类中创建了 Counter 类
    // 再次创建就会报错
    // 不过我们可以拿到类中,让他成为一个静态内部类.这样作用域不同,就不会报错了
    // 回顾:内部类
    // 普通内部类
    // 静态内部类:内部类前面加上static
    // 局部内部类:内部类定义到方法中,只在方法中生效
    // 匿名内部类:我们之前写过的lambda表达式实际上就是匿名内部类
    static class Counter {
        public int flag = 0;
    }

    // 效果:t2线程输入一个非0的数字
    // t1 线程结束,随之进程结束
    public static void main(String[] args) {
        Counter counter = new Counter();

        // t1 线程去读
        Thread t1 = new Thread(() -> {
            while(counter.flag == 0) {
                // 执行循环,啥都不做
            }
            System.out.println("t1 结束");
        });
        t1.start();

        // t2 线程去写
        // 让用户输入数字,赋值给flag
        Thread t2 = new Thread(() -> {
            Scanner scanner = new Scanner(System.in);
            System.out.println("请输入一个整数:");
            counter.flag = scanner.nextInt();
        });
        t2.start();
    }
}

当用户输入非 0 的数字的时候 , t1 线程并未结束
image.png
这就是我们之前介绍的内存可见性问题
image.png
编译器优化 , 这是属于编译器自带的功能 , 正常来说 , 程序员无法进行干预
但是因为上述场景还挺常见的 , 编译器也知道自己可能会产生误判 , 因此就给程序员提供了一个干预优化的途径

使用 volatile 关键字
这个关键字需要写到要修改的变量上的
image.png
volatile 操作相当于显式的禁止了编译器进行上述优化 , 是给对应的变量加上了"内存屏障" (特殊的二进制指令) ,
JVM 在读取这个变量的时候 , 因为内存屏障的存在 , 就知道要每次都重新读取这个内存的内容 , 而不是进行草率的优化 . (频繁读内存 , 速度是慢了 , 但是数据算的正确了!)

编译器的优化 , 是根据代码的实际情况来进行的 .
上面的代码循环体内是空的 , 所以循环体转速极快 , 导致读内存操作非常频繁 , 就进行了编译器优化
我们可以让循环转速没那么快
image.png
这个版本中加了 sleep , 让循环转速一下子就慢了 , 读内存操作就不是那么频繁了 , 就不会触发优化了
虽然没写 volatile , 但是也能正常运行了

编译器优化其实很多时候是一个"玄学问题" , 由于咱们也不好确定 , 啥时候优化 , 啥时候不优化 . 既然如此 , 就还是得在必要的时候加上 volatile

6.1 volatile 能保证内存可见性

我们刚才讲的是 CPU 和 内存之间的联系 , 线程优化之后 , 主要是在操作 CPU , 没有及时的去读内存 , 导致出现了误判
image.png
但实际是这个样子的 , 线程优化之后 , 主要在操作工作内存 , 没有及时读取主内存 , 导致出现误判
此处的工作内存不是真正的内存 , 其实就是 CPU 的寄存器 (还可能加上 CPU 缓存) , 其实英文更加贴切 , 叫做 work memory , 更贴切的翻译应该是主存储区 / 工作存储区
此处的主内存才是真正的内存

原理都是一样的 , 就是换了个名词
上述的过程 , Java 还单独起了个名字 , 叫做 JMM (JAva Memory Model)

为什么 Java 还要单独研究 JMM 呢 ?
这是因为 Java 的跨平台性 , 对于程序员来说 , 屏蔽底层硬件的细节
各种底层结构的不同 , 都会对上面的 “内存可见性” 的过程产生影响
(比如 : 早期的机器没有缓存 , 只有 CPU , 现在最先进的 CPU 有 L1、L2、L3三级缓存)

6.2 volatile 不保证原子性

volatile 解决的是一个线程读 , 一个线程写的问题
synchronized 解决的是两个线程写的问题
image.png

6.3 volatile 也可以禁止指令重排序

  • 6
    点赞
  • 6
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 10
    评论
评论 10
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

加勒比海涛

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值