共享带来的问题
小案例
两个线程对初始值为 0 的静态变量一个做自增,一个做自减,各做 5000 次,结果是 0 吗?
package cn.knightzz.example;
import lombok.extern.slf4j.Slf4j;
@SuppressWarnings("all")
@Slf4j(topic = "c.TestShareValue")
public class TestShareValue {
static int value = 0;
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
for (int i = 0; i < 5000; i++) {
value++;
log.debug("value + 1 = {}", value);
}
}, "t1");
Thread t2 = new Thread(() -> {
for (int i = 0; i < 5000; i++) {
value--;
log.debug("value - 1 = {}", value);
}
}, "t2");
t1.start();
t2.start();
t1.join();
t2.join();
log.debug("value : {} ", value);
}
}
问题分析
以上的结果可能是正数、负数、零。为什么呢?因为 Java 中对静态变量的自增,自减并不是原子操作,要彻底理
解,必须从字节码来进行分析
例如对于 i++ 而言(i 为静态变量),实际会产生如下的 JVM 字节码指令:
getstatic i // 获取静态变量i的值
iconst_1 // 准备常量1
iadd // 自增
putstatic i // 将修改后的值存入静态变量i
而对应 i-- 也是类似:
getstatic i // 获取静态变量i的值
iconst_1 // 准备常量1
isub // 自减
putstatic i // 将修改后的值存入静态变量i
而 Java 的内存模型如下,完成静态变量的自增,自减需要在主存和工作内存中进行数据交换:
如果是单线程以上 8 行代码是顺序执行(不会交错)没有问题:
出现负数的情况 :
临界资源
-
一个程序运行多个线程本身是没有问题的
-
- 问题出在多个线程访问共享资源
-
多个线程读共享资源其实也没有问题
-
- 在多个线程对共享资源读写操作时发生指令交错,就会出现问题
一段代码块内如果存在对共享资源的多线程读写操作,称这段代码块为临界区
static int counter = 0;
public void increment(){
// 临界区
counter++;
}
public void decrement(){
counter--;
}
竞态条件
多个线程在临界区内执行,由于代码的执行序列不同而导致结果无法预测,称之为发生了竞态条件
synchronized 解决方案
互斥
为了避免临界区的竞态条件发生,有多种手段可以达到目的 :
- 阻塞式的解决方案:synchronized,Lock
- 非阻塞式的解决方案:原子变量
synchronized对象锁 , 采用互斥的方式让同一时刻至多只有一个线程能持有对象锁,其它线程再想获取这个对象锁时就会阻塞住。这样就能保证拥有锁的线程可以安全的执行临界区内的代码,不用担心线程上下文切换
虽然 java 中互斥和同步都可以采用 synchronized 关键字来完成,但它们还是有区别的:
- 互斥是保证临界区的竞态条件发生,同一时刻只能有一个线程执行临界区代码
- 同步是由于线程执行的先后、顺序不同、需要一个线程等待其它线程运行到某个点
synchronized(对象) // 线程1, 线程2(blocked)
{
临界区
}
小案例
package cn.knightzz.test;
import lombok.extern.slf4j.Slf4j;
@SuppressWarnings("all")
@Slf4j(topic = "c.TestSynchronized")
public class TestSynchronized {
private static int counter = 0;
public static void main(String[] args) throws InterruptedException {
method2();
}
private static void method1() throws InterruptedException {
Thread t1 = new Thread(() -> {
for (int i = 0; i < 50000; i++) {
log.debug("counter add : {} ", counter);
counter++;
}
log.debug("t1 thread end ... ");
}, "t1");
Thread t2 = new Thread(() -> {
for (int i = 0; i < 50000; i++) {
log.debug("counter sub : {} ", counter);
counter--;
}
log.debug("t2 thread end ... ");
}, "t2");
t1.start();
t2.start();
t1.join();
t2.join();
// 最终结果可能是 正数, 负数或者 0
log.debug("method1 : counter = {}", counter);
// 重置counter
counter = 0;
}
private static void method2() throws InterruptedException {
Thread t1 = new Thread(() -> {
for (int i = 0; i < 500000; i++) {
synchronized (TestSynchronized.class) {
// 使用静态类对象作为 锁
log.debug("counter add : {} ", counter);
counter++;
}
}
log.debug("t1 thread end ... ");
}, "t1");
Thread t2 = new Thread(() -> {
for (int i = 0; i < 500000; i++) {
synchronized (TestSynchronized.class) {
log.debug("counter sub : {} ", counter);
counter--;
}
}
log.debug("t2 thread end ... ");
}, "t2");
t1.start();
t2.start();
t1.join();
t2.join();
// 最终结果可能是 正数, 负数或者 0
log.debug("method2 : counter = {}", counter);
// 重置counter
counter = 0;
}
}
synchronized 可以被认为是一个房间的钥匙, 临界区可以被认为是一个房间
- 当线程拿到锁以后, 会进入房间然后关上门, 此时其他线程无法进入临界区执行临界区的代码
- 如上面的图, 线程2到了门口, 会发现门在关着, 并且线程2无法获取锁, 所以只能被阻塞, 等待线程1执行结束释放锁
- 另外需要注意的是 : 并不是拿到锁就可以一直执行下去, CPU分配给线程1的时间片结束后, 线程1就会被踢出临界区, 然后门重新被锁住.
- 此时线程仍然拿着锁, 等到线程1再次获得CPU时间片后, 执行完临界区的代码, 释放锁后, 线程2才可以获取锁进入临界区去执行临界区的代码
图解如下 :
原子性的理解
for (int i = 0; i < 50000; i++) {
synchronized (TestSynchronized.class) {
// 使用静态类对象作为 锁
log.debug("counter add : {} ", counter);
counter++;
}
}
如上面的代码, 此时 synchronized
包裹的是 counter++;
这行代码 , 操作的是 counter
这个临界资源
counter++
在jvm里面有四步指令 :
- get static i 获取静态变量i
- iconst 准备常量
- iadd 自增
- putstatic 将i的值写入到 静态变量
原子性就是要保证, 这四步是完整不可分割的整体, 在这四步执行完以前, 任何线程都无法拿到锁 , 一旦这四步执行结束, 那么锁就会被释放, 所以 t1线程和t2线程可以交替混合打印
思考与总结
synchronized 实际是用对象锁保证了临界区内代码的原子性,临界区内的代码对外是不可分割的,不会被线程切
换所打断
思考下面的问题 : ?
- 如果把 synchronized(obj) 放在 for 循环的外面,如何理解?-- 原子性
- 如果 t1 synchronized(obj1) 而 t2 synchronized(obj2) 会怎样运作?-- 锁对象
- 如果 t1 synchronized(obj) 而 t2 没有加会怎么样?如何理解?-- 锁对象
问题1 :
- 如果把 synchronized(obj) 放在 for 循环的外面,如何理解?-- 原子性
上图需要注意的是 : 当一次counter++
或者counter--
执行结束以后, 对象锁就会被释放, 所以才会出现两个线程交替打印, 如果放在for循环外的话, 那么for循环就是一个整体, 具有原子性, 在执行结束前, 别的线程拿不到锁, 所以 此时是先后打印, 打印完线程1以后, 才会打印线程2
我们可以把存放临界区代码的地方看做一个房子, 这个房子只有一道门, 房子里面存放不同的临界区代码资源
// t1线程
synchronized (TestSynchronized.class) {
// 使用静态类对象作为 锁
log.debug("counter add : {} ", counter);
counter++;
}
// t2 线程
synchronized (TestSynchronized.class) {
log.debug("counter sub : {} ", counter);
counter--;
}
可以看到上面的代码, 使用的都是 TestSynchronized.class
这个类对象, TestSynchronized.class
是锁对象, 这个锁对象对应着一个房间(临界区) , 房间内存放着synchronized
包裹的临界区代码, 每当对应线程想要执行对应的代码的时候, 都要先获取锁.
回到问题 , 如果synchronized
在for
循环外包裹, 则
for
循环是临界代码, 当线程获取锁后, 会直接把for循环执行完, 锁才会释放- 整个for循环会被视为一个整体, 单个线程的for循环执行结束之前, 是不会受到其他线程的干扰
- 直观可以看到的就是 程序会先打印t1或者t2的输出, 等到t1或者t2执行完以后, 才会执行另外的线程的代码
假如是包裹在counter++
上 :
synchronized (TestSynchronized.class) {
log.debug("counter sub : {} ", counter);
counter--;
}
getstatic i // 获取i变量
iconst_1 // 准备常量1
iadd // i 自增1
putstatic i // 将1写入到i
因为加了 synchronized
, 上面的四行JVM指令是一个整体 , 不存在这四行指令尚未执行完毕的时候被其他线程干扰, 比如 : 执行到 **iadd**
后 准备执行 **putstatic i**
时线程上下文切换, 指令中断! , 这四个指令执行完以前, 不会被任何线程打断
可以看到 t1 和 t2 实际上是混合交替运行的
如果是包裹在 for 循环外的话 :
synchronized (TestSynchronized.class) {
for (int i = 0; i < 5000; i++) {
// 使用静态类对象作为 锁
log.debug("counter add : {} ", counter);
counter++;
}
}
如上面的代码, 相当于 for 循环被视为一个整体, 具有原子性,那么, 在for循环执行结束前, 不会受到其他线程的干扰
直观的感受, t1执行完毕以后, t2才获得锁对象开始执行
问题2 :
- 如果 t1 synchronized(obj1) 而 t2 synchronized(obj2) 会怎样运作?-- 锁对象
如果两个锁对象不同, 但是操作的同一个临界资源的话, 就相当于没有锁不会产生互斥 :
如上图所示 : 相当于有两个房间, 因为锁不同, 一把锁可以理解对应一个房间 , 所以当线程1获取 testQuestion1
的锁时, 并不会和线程2出现互斥, 不影响线程2获取 testQuestion2
锁, 所以最终结果就和都不加锁是一样的
也可以理解为另外开了一个门, 在同一时间, 两个线程都可以进入房间去修改临界资源
问题3
- 如果 t1 synchronized(obj) 而 t2 没有加会怎么样?如何理解?-- 锁对象
如果一个加锁, 另外一个不加, 也相当于都没加, 因为 t2线程可以随意的执行, 不需要获得锁就可以执行去修改临界资源的值
思考
其实如果想要保证原子性 , 就必须互斥 , 要保证, 所有的临界区代码都在一个房间, 也就是说要都使用同一把锁才可以
否则, 只要有一个临界区不在这个房间, 那么就无法保证原子性, 因为这个临界区的代码执行不受限制, 可以随意修改临界资源的
面向对象改进
把需要保护的共享变量放入一个类
package cn.knightzz.improve;
import lombok.extern.slf4j.Slf4j;
@SuppressWarnings("all")
@Slf4j(topic = "c.Room")
public class Room {
private int counter = 0;
public void increment() {
// 使用当前对象作为锁
synchronized(this) {
log.debug("increment counter : {} " , counter);
counter++;
}
}
public void decrement() {
// 使用当前对象作为锁
synchronized(this) {
log.debug("decrement counter : {} " , counter);
counter--;
}
}
public int getCounter() {
return counter;
}
}
然后使用时直接调用 Room
提供的方法即可
package cn.knightzz.improve;
import cn.knightzz.test.TestSynchronized;
import lombok.extern.slf4j.Slf4j;
/**
* @author 王天赐
* @title: TestObjectImprove
* @projectName hm-juc-codes
* @description: 面向对象改进
* @website <a href="http://knightzz.cn/">http://knightzz.cn/</a>
* @github <a href="https://github.com/knightzz1998">https://github.com/knightzz1998</a>
* @create: 2022-07-03 21:09
*/
@SuppressWarnings("all")
@Slf4j(topic = "c.Room")
public class TestObjectImprove {
public static void main(String[] args) throws InterruptedException {
Room room = new Room();
Thread t1 = new Thread(() -> {
// 在 for 循环执行的过程中, 不会受到其他线程的干扰
for (int i = 0; i < 5000; i++) {
room.increment();
}
log.debug("t1 thread end ... ");
}, "t1");
Thread t2 = new Thread(() -> {
// 在 for 循环执行的过程中, 不会受到其他线程的干扰
for (int i = 0; i < 5000; i++) {
room.decrement();
}
log.debug("t2 thread end ... ");
}, "t2");
t1.start();
t2.start();
t1.join();
t2.join();
log.debug("counter : {} ... ", room.getCounter());
}
}