Featured image of post Java编程思想 并发编程 21.3 共享受限资源

Java编程思想 并发编程 21.3 共享受限资源

🌏Java编程思想 第21章 并发 21.3 共享受限资源 🎯主要呈现的内容是对原书代码的理解、实践和反思

代码来源 Java编程思想 第四版 图片名称

🎄概述

有了并发 就意味着存在多个线程同时访问“临界资源”的情况,如果这种情况不能正确地加以防范和纠正(线程同步),那么应用程序在运行的过程中可能会有意想不到的错误发生

🍭轻松一刻

假如说你是一个线程 你坐在桌边手拿着筷子 正要去夹盘子中的最后一块肉 当你的筷子就要够着它的时候 这块肉突然消失了 因为这个时候你的CPU执行权被CPU没收了 你被挂起了 而另一个用餐者获得了CPU的执行权并用筷子夹走了这块肉…是不是很心塞🙃 这就是线程不同步的结果 哈哈哈

🎄不正确地访问资源

接下来用一个例子来演示互斥资源的不正确访问的例子 这个例子中有三个类

  • IntGenerator类对象的next()方法可以生成一个整数(具体这个正数如何生成 需要看下面的代码)
  • EvenChecker类用于检查next()方法生成的整数是不是偶数 如果不是偶数 则EvenChecker的所有子类将不再继续检查IntGenerator对象生成的整数 程序很快就会结束
  • EvenGeneratorIntGenerator的子类 持有一个初始化为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 SE5java.util.concurrent类库中还包含有定义的显示的互斥锁LockLock对象必须被显式地创建、锁定和释放 因此它与内置的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 oMonitor锁对象来实现对这个临界区加锁 事实上 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 次 可见性能差距非常非常大!

🎄在其他对象上同步