代码来源 Java编程思想 第四版
🎄概述
有了并发 就意味着存在多个线程同时访问“临界资源”的情况,如果这种情况不能正确地加以防范和纠正(线程同步),那么应用程序在运行的过程中可能会有意想不到的错误发生
🍭轻松一刻
假如说你是一个线程 你坐在桌边手拿着筷子 正要去夹盘子中的最后一块肉 当你的筷子就要够着它的时候 这块肉突然消失了 因为这个时候你的CPU执行权被CPU没收了 你被挂起了 而另一个用餐者获得了CPU的执行权并用筷子夹走了这块肉…是不是很心塞🙃 这就是线程不同步的结果 哈哈哈
🎄不正确地访问资源
接下来用一个例子来演示互斥资源的不正确访问的例子 这个例子中有三个类
IntGenerator
类对象的next()方法可以生成一个整数(具体这个正数如何生成 需要看下面的代码)EvenChecker
类用于检查next()方法生成的整数是不是偶数 如果不是偶数 则EvenChecker的所有子类将不再继续检查IntGenerator
对象生成的整数 程序很快就会结束EvenGenerator
是IntGenerator
的子类 持有一个初始化为0的整数(next()方法会把这个正数加2后返回)
其他具体细节需要看代码中的注释并执行 观察执行结果:
/**
@author Liu Xianmeng
@createTime 2023/9/7 22:54
@instruction
*/
@SuppressWarnings({"all"})
public abstract class IntGenerator { // 定义一个抽象类
// 保证此变量的可见性 同时boolean类型的变量是原子性的
private volatile boolean canceled = false;
// next生成一个int
public abstract int next();
// Allow this to be canceled:
// cancel或者不cancel的意义在于什么?cancel之后 其他的线程探测到这个对象不会再生成整数 也就不会再进行检查
public void cancel() { canceled = true; }
public boolean isCanceled() { return canceled; }
} ///:~
/**
@author Liu Xianmeng
@createTime 2023/9/9 20:13
@instruction
*/
@SuppressWarnings({"all"})
public class EvenChecker implements Runnable {
// 偶数检查类 EvenChecker 持有一个 IntGenerator对象
private IntGenerator generator;
private final int id; // 只能被初始化一次
// 初始化 EvenChecker类对象的id(id唯一标识一个EvenChecker对象)
public EvenChecker(IntGenerator g, int ident) {
generator = g;
id = ident;
}
// 定义Task
@Override
public void run() {
/*
当生成器还没有被cancel的时候 就生成一个正数 并check这个正数是不是偶数
而在public static void test(IntGenerator gp) {...}函数中gp对象是作为共享对象传进去的
这就说明一旦有一个EvenChecker对象的Task执行到了generator.cancel();
后面还没有进入while(!generator.isCanceled()) {...}代码区的线程就不能再进去了
而已经进入的线程还是会执行while循环中的所有代码
通过这个例子来演示:错误的共享IntGenerator类对象的方式
*/
while(!generator.isCanceled()) {
int val = generator.next();
if(val % 2 != 0) {
// 同时 这个正数只会生成一次 check之后生成器就被cancel了
System.out.println(val + " not even!");
generator.cancel(); // Cancels all EvenCheckers
}
}
}
// Test any type of IntGenerator:
public static void test(IntGenerator gp, int n) {
System.out.println("Press Control-C to exit");
ExecutorService exec = Executors.newCachedThreadPool();
// 提交n个线程任务
for(int i = 0; i < n; i++) {
// i变量刚好作为EvenChecker类对象的唯一标识
exec.execute(new EvenChecker(gp, i));
}
exec.shutdown();
}
// Default value for n:
public static void test(IntGenerator gp) {
test(gp, 10);
}
} ///:~
/**
@author Liu Xianmeng
@createTime 2023/9/9 20:32
@instruction
*/
@SuppressWarnings({"all"})
public class EvenGenerator extends IntGenerator {
private int currentEvenValue = 0;
@Override
public int next() {
++currentEvenValue; // Danger point here! Danger! Danger!(非原子性)
++currentEvenValue; //(非原子性)
return currentEvenValue;
}
public static void main(String[] args) {
EvenChecker.test(new EvenGenerator());
}
} /* Output: (Sample)
Press Control-C to exit
89476993 not even!
89476993 not even!
*///:~
总结来说 就是多个EvenChecker
类的对象(作为多个线程) 去检查EvenGenerator
的一个对象生成的整数是不是偶数,只要有一个线程检查出来不是偶数 则多个线程陆续结束
这里的重点就是next()
方法并没有加锁,才有了最后的运行结果,理论上我们希望89476993 not even!
一次都不要打印,这是线程同步的目的 我们看到++currentEvenValue;
本身是非原子性的 所以光是一个++currentEvenValue;
操作 就可以导致程序的不可预期的运行结果 更不用说一个next()函数中有两个++currentEvenValue;
操作呢 如果在执行一次++currentEvenValue;
后让程序sleep()那么最终的打印的错误结果呈现的会更快
🎄解决共享资源的竞争
如果解决共享资源的竞争 以防止多线程下不可预见的错误? 可以将关心的代码块进行同步,也可以直接把整个方法进行同步 这里使用synchronized
关键字标识next()
方法 当有一个线程要执行next()方法 就必须先获取到持有该方法的对象的Monitor锁对象 执行结束后再释放该锁 以便下一个线程来执行next()方法(同步后运行 程序理论上永远不会停止 因为每次check都是偶数 也就不会跳出while循环):
/**
@author Liu Xianmeng
@createTime 2023/9/9 21:17
@instruction
*/
@SuppressWarnings({"all"})
public class SynchronizedEvenGenerator extends IntGenerator {
private int currentEvenValue = 0;
// 一次只能有一个线程进入 这样的话获取到的currentEvenValue永远都是偶数 程序永远都不会停下
public synchronized int next() {
++currentEvenValue;
Thread.yield(); // Cause failure faster
++currentEvenValue;
return currentEvenValue;
}
public static void main(String[] args) {
EvenChecker.test(new SynchronizedEvenGenerator());
}
} ///:~
🍭关于synchronized
关键字
在Java中 每一个对象都有关联自己的一把锁(Monitor
)因此每一个对象都可以当做一把锁来使用 当一个临界资源被synchronized关键字修饰 意味同一时刻只能有一个线程访问该资源 线程获取到该资源的锁之后 对临界资源进行访问 访问结束后归还释放锁 其他的线程才能接着访问该资源
synchronized
可以修饰哪些程序段?1️⃣修饰方法2️⃣修饰代码块 这里先只讨论修饰方法的情况 其实同步代码块只是相对于同步方法来说缩小了同步范围 同步代码块的执行(获取到Monitor锁对象) 也会导致整个对象上所有的同步方法或其他同步块被加锁
- 当
synchronized
修饰方法的时候 第1个要注意的是 当被调用类中其中一个被synchronized修饰的方法(临界资源)被访问的时候 其他所有被synchronized修饰的方法同样会被阻塞🙃 是的你没有听错 相当于各个被调用的synchronized修饰的方法之间会同时锁定和释放(所有的synchronized方法共享同一把锁 也就是上面提到的Monitor
) - 当
synchronized
修饰方法的时候 第2个要注意的是 一个Task(线程任务)可以多次获得对象的Monitor
什么意思?就是说当一个Task已经正在执行对象的一个synchronized方法 这个时候这个Task已经获取到对象的锁 如果这个时候 这个Task还需要执行第二个synchronized方法 就会再次对方法所在的对象加锁 因此 每一个对象都有一个加锁次数 JVM负责跟踪对象被加锁的次数 ❗如果一个对象被完全解锁 则计数为0 只有在这个时候 对象才可能被一个新的Task锁定 ❗如果一个对象被同一个Task加锁了两次 这个时候计数为2 只有该Task把2把锁全部释放 被调用的对象才算重新处于“可重新被一个Task支配的状态”
那什么时候需要同步❓
Brian同步规则
如果你正在写一个变量 并且它接下来可能会被另一个线程读取 或者正在读取一个上一次已经被其他线程写过的一个变量 这个时候必须进行同步 并且 读写线程都必须用相同的Monitor监视器锁同步
上面的读写线程都必须用相同的Monitor监视器锁同步是指当一个线程正在access
该变量的时候,其他所有线程就不能access该变量 【注意】这里的access
包含读和写两层意思
⚡练习11
📑【题目描述】1️⃣ 模拟上面给出的三个类的示例 创建一个类 这个类有两个数据域和一个操作这些数据域的方法 其操作步骤是多步的 2️⃣ 在这个操作方法中 如果不加以同步 那么在多线程访问这个方法的时候 要出现一些“不正确的状态” 这个不正确的状态具体如何呈现 由程序员自己定义 3️⃣使用synchronized关键字修复这个问题
/**
@author Liu Xianmeng
@createTime 2023/9/9 21:41
@instruction 【题目描述】
1️⃣ 模拟上面给出的三个类的示例 创建一个类 这个类有两个数据域和一个操作这些数据域的方法 其操作步骤是多步的
2️⃣ 在这个操作方法中 如果不加以同步 那么在多线程访问这个方法的时候 要出现一些“不正确的状态” 这个不正确的状态具体如何呈现 由程序员自己定义
3️⃣ 使用synchronized关键字修复这个问题
*/
@SuppressWarnings({"all"})
public class Practice_11 {
//1️⃣ 模拟上面给出的三个类的示例 创建一个类 这个类有两个数据域
private int state_01 = 1;
private int state_02 = 1;
//1️⃣ 和一个操作这些数据域的方法
//3️⃣ 使用synchronized关键字修复这个问题
public synchronized void changeState() {
//1️⃣其操作步骤是多步的
state_01++;
try {
Thread.sleep(100);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
state_02++;
}
// 连个字符串是否相同
public boolean ifSame() {
if(state_01 == state_02){
return true;
} else {
return false;
}
}
}
class Visitor implements Runnable {
Practice_11 p;
int sequence; // 线程的序列号
public Visitor(Practice_11 p, int sequence){
this.p = p;
this.sequence = sequence;
}
@Override
public void run() {
// 如果相同 就将字符串修改
while(p.ifSame()) {
// 修改字符串状态
p.changeState();
}
System.out.println("状态不同 [" + this.sequence + "] 线程退出..");
}
public static void main(String[] args) {
ExecutorService exec = Executors.newCachedThreadPool();
Practice_11 p = new Practice_11();
for (int i = 0; i < 20; i++) {
exec.submit(new Visitor(p, i));
}
exec.shutdown();
}
/*2️⃣ 在这个操作方法中 如果不加以同步 那么在多线程访问这个方法的时候 要出现一些“不正确的状态” 这个不正确的状态具体如何呈现 由程序员自己定义
未同步的执行结果:
状态不同 [1] 线程退出..
状态不同 [3] 线程退出..
状态不同 [2] 线程退出..
状态不同 [5] 线程退出..
状态不同 [4] 线程退出..
状态不同 [6] 线程退出..
状态不同 [8] 线程退出..
状态不同 [7] 线程退出..
状态不同 [9] 线程退出..
状态不同 [15] 线程退出..
状态不同 [13] 线程退出..
状态不同 [18] 线程退出..
状态不同 [16] 线程退出..
状态不同 [14] 线程退出..
状态不同 [11] 线程退出..
状态不同 [12] 线程退出..
状态不同 [10] 线程退出..
状态不同 [19] 线程退出..
状态不同 [17] 线程退出..
同步后程序永不停止 不会有任何输出
*/
}
🎄使用显式的Lock
对象
Java SE5
的java.util.concurrent
类库中还包含有定义的显示的互斥锁Lock
,Lock
对象必须被显式地创建、锁定和释放 因此它与内置的Monitor锁的形式相比缺乏优雅性 但是对于解决某些类型的问题来说 它更加灵活 看下面的程序示例
/**
@author Liu Xianmeng
@createTime 2023/9/10 9:11
@instruction
*/
@SuppressWarnings({"all"})
public class MutexEvenGenerator extends C_1_IntGenerator {
private int currentEvenValue = 0;
// 创建一个Lock对象 被互斥调用
private Lock lock = new ReentrantLock();
@Override
public int next() {
lock.lock(); // 加锁
try {
++currentEvenValue;
Thread.yield(); // Cause failure faster
++currentEvenValue;
// return语句在try块中出现 以确保unlock不会过早发生 从而将数据暴露给下一个线程
return currentEvenValue;
} finally {
// 释放锁的操作必须在finally块中进行
lock.unlock();
}
}
public static void main(String[] args) {
C_2_EvenChecker.test(new C_6_MutexEvenGenerator());
}
}
看起来显式的Lock对象似乎需要配合着try-catch-finally来使用 对吗?是的 这也是显式的Lock对象的优点之一的来源所在 在我们使用synchronized关键字的时候 如果某些事务失败了 就立即会抛出一个异常 所以程序员没有机会去做任何的清理工作 以维护系统使其处于一个良好的状态 但有了显式的Lock对象 程序员就可以使用finally字句将系统维护在正确的状态
🍭小结一下
大体上 我们在使用synchronized关键字的时候 需要写的代码量更少 并且用户错误出现的可能性也会降低(显式地控制Lock的创建、锁定和释放势必比内置锁更容易发生意想不到的疏忽和漏洞)因此通常在解决特殊问题的时候 才会使用显式的Lock锁 具体见下面的代码示例(老实说 这个类的代码目前以我的水平没有看懂…🙃 先放在这叭 后面程度够了 再来解读这个类)
public class AttemptLocking {
// re entrant lock -> 可重入锁
private ReentrantLock lock = new ReentrantLock();
public void untimed() {
boolean captured = lock.tryLock();
try {
System.out.println("tryLock(): " + captured);
} finally {
if(captured) {
lock.unlock();
}
}
}
public void timed() {
boolean captured = false;
try {
captured = lock.tryLock(2, TimeUnit.SECONDS);
} catch(InterruptedException e) {
throw new RuntimeException(e);
}
try {
System.out.println("tryLock(2, TimeUnit.SECONDS): " + captured);
} finally {
if(captured) {
lock.unlock();
}
}
}
public static void main(String[] args) {
final C_7_AttemptLocking al = new C_7_AttemptLocking();
al.untimed(); // True -- lock is available
al.timed(); // True -- lock is available
// Now create a separate task to grab the lock:
new Thread() {
{ setDaemon(true); }
public void run() {
al.lock.lock();
System.out.println("acquired");
}
}.start();
Thread.yield(); // Give the 2nd task a chance
al.untimed(); // False -- lock grabbed by task
al.timed(); // False -- lock grabbed by task
}
} /* Output:
tryLock(): true
tryLock(2, TimeUnit.SECONDS): true
acquired
tryLock(): false
tryLock(2, TimeUnit.SECONDS): false
*///:~
🎄原子性和易变性
😊终于读到这个一节了 由于之前已经接触到这个概念 所以很期待这本书会如何来讲解和描述这个知识点 接下来就开始吧~
🍥原子性可以用于除了long和double类型之外的所有基本类型之上的“简单操作”
为什么这么说?因为JVM可以将64位的long和double变量的读取和写入当做两个分离的32位操作来执行,这就使得在一个读取和写入long和double变量期间可能会发生上下文切换 从而导致不同任务可以看到不正确结果的可能
使用volatile
关键字可以避免这个情况 强制使得long和double操作成为原子性操作(原子操作不可被中断 执行过程中不允许存在上下文切换)
不同的JVM可以提供不同强度的volatile
原子性的保证 因此不能依赖于平台的相关原子性的保证
使用volatile
而不是synchronized
的唯一安全的情况是类中只有一个可变的域 程序员的第一选择应该是使用synchronized
关键字 这是最安全的方式 其他的任何方式都是有风险的
🍭Java中的i++操作不是原子性的
下面将用一个简单代码反编译查看Java编译后的字节码
源代码如下:
public class C_8_Atomicity {
int i;
void f1() { i++; }
void f2() { i += 3; }
}
在CommandLine执行 javap -c C_8_Atomicity
查看输出结果:
可见一个i++
操作的执行用了多步 并不是一步完成的 执行i++
会把i所在内存的值加1 但这个过程包括 取出变量i 执行+1操作 写回到内存等
2: getfield #2 // Field i:I 5: iconst_1 6: iadd 7: putfield #2 // Field i:I
在get和put之间 任何一个其他的任务都有可能会修改这个域 所以这个i++
操作不是原子性的
🍭盲目使用原子性可能造成的后果
/**
@author Liu Xianmeng
@createTime 2023/9/10 10:39
@instruction 盲目使用原子性可能造成的后果
*/
@SuppressWarnings({"all"})
public class C_9_AtomicityTest implements Runnable {
// i不能保证可见性
private int i = 0;
// 缺少同步 使得i的数值可能在不稳定的中间状态被读取 所以这个方法必须被同步
public int getValue() {
return i; // return i 是原子性操作 但是
}
private synchronized void evenIncrement() { i++; i++; }
@Override
public void run() {
while(true) {
evenIncrement();
}
}
public static void main(String[] args) {
ExecutorService exec = Executors.newCachedThreadPool();
C_9_AtomicityTest at = new C_9_AtomicityTest();
exec.execute(at);
while(true) {
int val = at.getValue();
if(val % 2 != 0) {
System.out.println(val);
System.exit(0);
}
}
}
} /* Output: (Sample)
191583767
*///:~
第二个示例 基本上 如果一个域可能被多个任务同时访问 或者这些任务中至少有一个是写入任务 那么就应该将这个域设置为volatile的 但 volatile
并不会对递增操作不是原子性这一事实产生影响
/**
@author Liu Xianmeng
@createTime 2023/9/10 10:47
@instruction
*/
@SuppressWarnings({"all"})
public class C_10_SerialNumberGenerator {
// 保证可见性
private static volatile int serialNumber = 0;
public static int nextSerialNumber() {
// 未同步 线程不安全 即便使用了volatile关键字进行修饰
return serialNumber++; // Not thread-safe
}
} ///:~
接下来用下面的代码来验证 volatile
并不会对递增操作不是原子性这一事实产生影响 也就是说volatile并不能保证变量的原子性
/**
@author Liu Xianmeng
@createTime 2023/9/10 10:53
@instruction 验证volatile关键字并不能保证变量的原子性
*/
@SuppressWarnings({"all"})
public class C_11_SerialNumberChecker {
// 要启动的SerialChecker线程数量
private static final int SIZE = 10;
/**
SerialNumberChecker包含一个静态的CircularSet 大小为1000
*/
private static CircularSet serials = new CircularSet(1000);
// exec线程池
private static ExecutorService exec = Executors.newCachedThreadPool();
// 定义静态内部类SerialChecker run()方法中定义Task
static class SerialChecker implements Runnable {
@Override
public void run() {
/**
* 直到遇到一个重复的数字 否则true循环持续获取数字
* 我们知道由于nextSerialNumber()并不是原子性的 所以在多线程下可能会发生不安全的读
* 也就是volatile修饰了serialNumber 但并不能保证其原子性
*/
while(true) {
// 使用非线程安全的方法nextSerialNumber()获取数字
int serial = C_10_SerialNumberGenerator.nextSerialNumber();
if(serials.contains(serial)) {
System.out.println("Duplicate: " + serial);
System.exit(0);
}
serials.add(serial);
}
}
}
public static void main(String[] args) throws Exception {
for(int i = 0; i < SIZE; i++) {
exec.execute(new SerialChecker());
}
// Stop after n seconds if there's an argument:
if(args.length > 0) {
TimeUnit.SECONDS.sleep(new Integer(args[0]));
System.out.println("No duplicates detected");
System.exit(0);
}
}
/**** 20230910 我的笔记本执行结果: ********
Duplicate: 4623
**** 也就是说 当某个线程获取到第4623个数字的时候 已经有其他线程获取过该数字了 ****
**** 这也就证明了nextSerialNumber()方法确实是线程不安全的 必须对其进行同步 *****/
}
// Reuses storage so we don't run out of memory:
// 重用存储以便我们不会耗尽内存:
class CircularSet {
private int[] array;
private int len; // 数组的大小
private int index = 0;
public CircularSet(int size) {
array = new int[size]; // 初始化数组
len = size;
// Initialize to a value not produced
// by the SerialNumberGenerator:
for(int i = 0; i < size; i++) {
// 初始化数组的值都为-1
array[i] = -1;
}
}
/******************* 读写array域的方法都被同步 ********************/
public synchronized void add(int i) {
array[index] = i;
// Wrap index and write over old elements:
// 更新索引 %循环存储
index = ++index % len;
}
public synchronized boolean contains(int val) {
// 查找数组中是否存在此val 并返回布尔值
for(int i = 0; i < len; i++) {
if(array[i] == val) return true;
}
return false;
}
}
⚡练习12
📑【题目描述】使用
synchronized
关键字修复nextSerialNumber()
方法
public synchronized static int nextSerialNumber() {
// 同步后 线程安全 不会再读出相同的数字
return serialNumber++; // thread-safe
}
🎄原子类的使用
对于常规的编程来说 原子类很少会派上用场 但是涉及到性能优化的时候 它们就会大有用处
🍭使用AtomicInteger
重写AtomicityTest
类
/**
@author Liu Xianmeng
@createTime 2023/9/10 15:19
@instruction
*/
@SuppressWarnings({"all"})
public class C_12_AtomicIntegerTest implements Runnable {
// 定义一个原子整数类 赋值为0
private AtomicInteger i = new AtomicInteger(0);
public int getValue() { return i.get(); }
// 偶数递增
private void evenIncrement() { i.addAndGet(2); }
@Override
public void run() {
// 持续偶数自增
while(true) {
evenIncrement();
}
}
// 因为在main()方法中直接创建并调用 所以要用static修饰
static class NumberChecker implements Runnable {
// 持有一个C_12_AtomicIntegerTest对象
private C_12_AtomicIntegerTest ait;
public NumberChecker(C_12_AtomicIntegerTest ait) {
this.ait = ait;
}
@Override
public void run() {
// 定义Task检查ai中的整数是不是偶数
while(true) {
int val = ait.getValue();
if(val % 2 != 0) {
System.out.println(val);
System.exit(0);
}
}
}
}
public static void main(String[] args) {
// 设置程序的执行结束时间
new Timer().schedule(new TimerTask() {
@Override
public void run() {
// 如果在ExecutorService执行线程过程中始终没有遇到奇数
// 则5秒钟后程序自动终止
System.err.println("Aborting");
System.exit(0);
}
}, 5000); // Terminate after 5 seconds
ExecutorService exec = Executors.newCachedThreadPool();
C_12_AtomicIntegerTest ait = new C_12_AtomicIntegerTest();
for (int i = 0; i < 20; i++) {
// 启动20个线程来并发读取ait的值 验证其原子性(线程安全)
exec.execute(new NumberChecker(ait));
}
/**
* [20230910] OutPut:
* Aborting
*/
}
}
很显然 getValue()
和 evenIncrement()
方法都没有被同步 但是在20个NumberChecker
的任务并发执行中却没有出现错误 所以private AtomicInteger i = new AtomicInteger(0);
中AtomicInteger
所定义的变量天然支持原子性和可见性
🌰再看下面的一个例子:用AtomicInteger
重写C_6_MutexEvenGenerator
类
执行结果是程序永不停止
/**
@author Liu Xianmeng
@createTime 2023/9/10 15:39
@instruction 用`AtomicInteger`重写`C_6_MutexEvenGenerator`类
*/
@SuppressWarnings({"all"})
public class C_13_AtomicEvenGenerator extends C_1_IntGenerator {
private AtomicInteger currentEvenValue = new AtomicInteger(0);
public int next() {
return currentEvenValue.addAndGet(2);
}
public static void main(String[] args) {
C_2_EvenChecker.test(new C_13_AtomicEvenGenerator());
}
} ///:~
虽然
AtomicInteger
支持原子性和可见性 但是通常情况下很少使用 常规的并发同步思路仍然是同步 要么使用synchronized
要么使用显式的Lock
锁
⚡练习13
📑【题目描述】创建一个程序 它可以生成许多
Timer
对象 这些对象在定时时间到达后将执行特定的某个简单的任务 用这个程序来证明java.util.Timer
可以扩展到很大的数目
/**
@author Liu Xianmeng
@createTime 2023/9/10 15:53
@instruction
*/
@SuppressWarnings({"all"})
public class C_13_Practice_13 {
public static void main(String[] args) {
int numberOfTimers = 10000; // 定时器的数量
for (int i = 0; i < numberOfTimers; i++) {
Timer timer = new Timer();
timer.schedule(new TimerTask() {
@Override
public void run() {
// 执行特定任务
System.out.println("Timer task 被执行.");
}
}, 1000); // 设置定时时间为1秒
}
/**
* [运行结果:打印10000次]
* Timer task executed.
* Timer task executed.
* Timer task executed.
* ...
*/
}
}
🎄临界区
🌱有时 我们只是希望防止多个线程同时访问方法内部的一部分代码 而不是整个函数 这个时候我们可以使用
synchronized
关键字将其包裹起来 被包裹的代码段就称为一个临界区 类似下面的定义:synchronized(object o){ // 临界区代码 也称为同步控制块 }
这里的
object o
是在定义临界区的时候要传入一个锁对象 我们在前面说明 一个Java对象都拥有一个Monitor
锁对象 因此可以说是借助了object o
的Monitor
锁对象来实现对这个临界区加锁 事实上synchronized
修饰方法是接住了类对象本身的Monitor
锁对象 所以使用synchronized
不管是定义临界区还是对方法加锁 原理都是一样的 线程要访问synchronized
修饰的代码段 就需要先获取到对应的对象锁 获取不到锁就只能被阻塞 直到获取到为止通过定义同步控制快 而不是对整个方法加锁 可以使多个任务访问对象的时间性能得到显著提高 因此我们可以得到 对整个方法加锁其实是一种重量级的锁 而同步代码块是相对的轻量级锁
🍭下面的示例演示了对方法加锁和对代码块加锁的性能差别(这个示例代码量稍微有点大🙂…需要花一定的时间去理清作者要传达的信息)
/**
@author Liu Xianmeng
@createTime 2023/9/10 16:14
@instruction
*/
// Not thread-safe
// Pair类有两个数据域 x和y 这是一个临界资源并未被同步的类 因此线程不安全
class Pair {
private int x, y;
public Pair(int x, int y) {
this.x = x;
this.y = y;
}
public Pair() { this(0, 0); }
public int getX() { return x; }
public int getY() { return y; }
public void incrementX() { x++; }
public void incrementY() { y++; }
public String toString() {
return "x: " + x + ", y: " + y;
}
// 定义一个异常类 当检测Pair数对的x和y不相等的时候抛出此异常
public class PairValuesNotEqualException extends RuntimeException {
public PairValuesNotEqualException() {
super("Pair values not equal: " + Pair.this);
}
}
// both variables must be equal:
// 两个变量必须相等
public void checkState() {
if(x != y) {
throw new PairValuesNotEqualException();
}
}
}
// Protect a Pair inside a thread-safe class:
// 20230910 使用模板方法模式定义PairManager
abstract class PairManager {
// 持有一个检查计数器 用于计数这个PairManager对持有的Pair检查了多少次
AtomicInteger checkCounter = new AtomicInteger(0);
// 持有的Pair对象
protected Pair p = new Pair();
// 下面创建了一个私有的名为storage的List对象 该对象的泛型为Pair
// 同时 通过使用Collections.synchronizedList方法 将该List对象转化为一个线程安全的List对象
private List<Pair> storage = Collections.synchronizedList(new ArrayList<Pair>());
// 获取Pair 线程安全
public synchronized Pair getPair() {
// Make a copy to keep the original safe:
return new Pair(p.getX(), p.getY());
}
// Assume this is a time consuming operation
protected void store(Pair p) {
// storage是线程安全的List<Pair>对象
storage.add(p);
try {
TimeUnit.MILLISECONDS.sleep(50);
} catch(InterruptedException ignore) {}
}
// 定义变量自增的函数 具体实现留给子类
public abstract void increment();
}
// Synchronize the entire method:
class PairManager1 extends PairManager {
@Override
public synchronized void increment() { // 对整个方法加锁
p.incrementX();
p.incrementY();
// ❗❗注意 因为getPair()方法是加锁的方法 所以会影响increment()函数的执行效率
// 当线程进入increment()方法后 要向执行getPair()方法 则还需要再加第二把锁
// 因此两个加锁方法的时间上的差别主要是increment()方法导致的
store(getPair());
}
}
// Use a critical section:
class PairManager2 extends PairManager {
@Override
public void increment() {
Pair temp;
synchronized(this) { // 使用临界区锁
p.incrementX();
p.incrementY();
temp = getPair();
}
store(temp);
}
}
// Pair操纵器 执行变量的自增
class PairManipulator implements Runnable {
private PairManager pm;
public PairManipulator(PairManager pm) {
this.pm = pm;
}
@Override
public void run() {
while(true) {
pm.increment();
}
}
@Override
public String toString() {
return "Pair: " + pm.getPair() + " checkCounter = " + pm.checkCounter.get();
}
}
// Pair检测类 执行checkCounter的自增并对Pair执行checkState()
class PairChecker implements Runnable {
private PairManager pm;
public PairChecker(PairManager pm) {
this.pm = pm;
}
@Override
public void run() {
while(true) {
pm.checkCounter.incrementAndGet();
pm.getPair().checkState();
}
}
}
@SuppressWarnings({"all"})
public class C_14_CriticalSection {
// Test the two different approaches:
static void testApproaches(PairManager pman1, PairManager pman2) {
ExecutorService exec = Executors.newCachedThreadPool();
// 操纵器
PairManipulator
pm1 = new PairManipulator(pman1), // 方法同步
pm2 = new PairManipulator(pman2); // 临界区同步
// 检测器
PairChecker
pcheck1 = new PairChecker(pman1), // 方法同步
pcheck2 = new PairChecker(pman2); // 临界区同步
exec.execute(pm1);
exec.execute(pm2);
exec.execute(pcheck1);
exec.execute(pcheck2);
try {
TimeUnit.MILLISECONDS.sleep(500);
} catch(InterruptedException e) {
System.out.println("Sleep interrupted");
}
System.out.println("pm1: " + pm1 + "\npm2: " + pm2);
System.exit(0);
}
public static void main(String[] args) {
PairManager
pman1 = new PairManager1(),
pman2 = new PairManager2();
testApproaches(pman1, pman2);
}
}
/* Output: 20230910
pm1: Pair: x: 31, y: 31 checkCounter = 140
pm2: Pair: x: 32, y: 32 checkCounter = 55240588
*///:~
⚡从代码的执行结果来看 在500毫秒之内 方法同步块方式中的checkCounter
递增了140
次 而临界区同步块的checkCounter
执行了55240588
次 可见性能差距非常非常大!