文章目录
本篇文章 , 会给大家继续讲解多线程当中的 synchronized 关键字以及 volatile 关键字 , synchronized 能够保证内存的原子性 , 我们需要了解这个特性 . 然后讲解了 volatile 关键字 , volatile 能保证内存可见性 , 但是 volatile 不保证原子性 , 然后 volatile 也可以禁止指令重排序 , 在这一篇文章大家都可以了解到
推荐大家跳转语雀页面进行观看 , 点击链接即可跳转
上一篇文章也给大家贴在这里了上一篇文章点击即可跳转
各位看官慢慢观看~
五 . synchronized 关键字
我们刚才刚刚分析过 : 我们可以将不是原子操作的代码通过 synchronized
关键字打包成一个原子的操作
比如 : 我们去 ATM 取钱
加锁有很多方式 , 我们常见的就是 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);
}
}
那怎样解决呢 ?
我们在自增操作的方法前面加上 synchronized
那为什么加了 synchronized
之后 , 结果就正确了呢 ?
由于加锁操作 , 产生了阻塞等待 , 强行把后面的 LOAD ADD SAVE 与前面的岔开了 , 这样就能够保证一个线程 SAVE 之后 , 另一个线程才 LOAD , 这样的话计算结果就精确了
5.2 synchronized 使用示例
5.2.1 直接修饰普通方法
我们刚才也介绍过了 , 直接在方法前面加上 synchronized
关键字即可
5.2.2 修饰静态方法
与修饰普通方法一样 , 在前面加上 synchronized
关键字即可
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);
}
}
在我们的代码中 , 锁对象就是 counter 对象
修饰普通方法和修饰静态方法的锁对象还不太相同
无论是使用哪种用法 , 使用 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
关键字
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();
}
}
那我们要把线程 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();
}
}
加上同一把锁 , 就只能 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 线程并未结束
这就是我们之前介绍的内存可见性问题
编译器优化 , 这是属于编译器自带的功能 , 正常来说 , 程序员无法进行干预
但是因为上述场景还挺常见的 , 编译器也知道自己可能会产生误判 , 因此就给程序员提供了一个干预优化的途径
使用 volatile
关键字
这个关键字需要写到要修改的变量上的
volatile 操作相当于显式的禁止了编译器进行上述优化 , 是给对应的变量加上了"内存屏障" (特殊的二进制指令) ,
JVM 在读取这个变量的时候 , 因为内存屏障的存在 , 就知道要每次都重新读取这个内存的内容 , 而不是进行草率的优化 . (频繁读内存 , 速度是慢了 , 但是数据算的正确了!)
编译器的优化 , 是根据代码的实际情况来进行的 .
上面的代码循环体内是空的 , 所以循环体转速极快 , 导致读内存操作非常频繁 , 就进行了编译器优化
我们可以让循环转速没那么快
这个版本中加了 sleep , 让循环转速一下子就慢了 , 读内存操作就不是那么频繁了 , 就不会触发优化了
虽然没写 volatile , 但是也能正常运行了
编译器优化其实很多时候是一个"玄学问题" , 由于咱们也不好确定 , 啥时候优化 , 啥时候不优化 . 既然如此 , 就还是得在必要的时候加上 volatile
6.1 volatile 能保证内存可见性
我们刚才讲的是 CPU 和 内存之间的联系 , 线程优化之后 , 主要是在操作 CPU , 没有及时的去读内存 , 导致出现了误判
但实际是这个样子的 , 线程优化之后 , 主要在操作工作内存 , 没有及时读取主内存 , 导致出现误判
此处的工作内存不是真正的内存 , 其实就是 CPU 的寄存器 (还可能加上 CPU 缓存) , 其实英文更加贴切 , 叫做 work memory
, 更贴切的翻译应该是主存储区 / 工作存储区
此处的主内存才是真正的内存
原理都是一样的 , 就是换了个名词
上述的过程 , Java 还单独起了个名字 , 叫做 JMM (JAva Memory Model)
为什么 Java 还要单独研究 JMM 呢 ?
这是因为 Java 的跨平台性 , 对于程序员来说 , 屏蔽底层硬件的细节
各种底层结构的不同 , 都会对上面的 “内存可见性” 的过程产生影响
(比如 : 早期的机器没有缓存 , 只有 CPU , 现在最先进的 CPU 有 L1、L2、L3三级缓存)
6.2 volatile 不保证原子性
volatile 解决的是一个线程读 , 一个线程写的问题
synchronized 解决的是两个线程写的问题