多线程强化(中)

简介: 多线程强化(中)

三、synchronized 原理



结合上面对锁策略的描述,就可以对 synchronized 先做出一个直观的认识:


① synchronized 在开始使用的时候,它是乐观锁,如果发现锁冲突概率比较高,就会自动转成悲观锁。

② synchronized 不是读写锁。

③ synchronized 开始的时候是轻量级锁,如果锁被持有的时间较长,或者锁的冲突概率较高,就会升级成重量级锁。

④ synchronized 是一个非公平锁。

⑤ synchronized 是一个可重入锁。

⑥ synchronized 为轻量级锁的时候,大概率是一个自旋锁,为重量级锁的时候大概率是一个挂起等待锁。


1. 锁升级


锁升级的过程:


1009bbe6da554162aed6f7121744d11b.png


偏向锁是使第一个尝试加锁的线程,优先进入偏向锁状态。偏向锁本质上相当于 " 延迟加锁 ", 能不加锁就不加锁,尽量来避免不必要的加锁开销。但是该做的标记还是得做的,否则无法区分何时需要真正加锁。它起到了一个预判的作用。


偏向锁不是真的 " 加锁 ",只是给对象头中做一个 " 偏向锁的标记 ",记录这个锁属于哪个线程。如果后续没有其他线程来竞争该锁,那么此次操作就不会加锁,从而避免了加锁解锁的开销;如果后续有其他线程来竞争该锁,那就取消原来的偏向锁状态,进入一般的轻量级锁状态。


举个例子:这就像武侠小说中的 " 投石问路 " 一样,一个人在机关重重的地方冒险,当他进入关卡之前,就要先扔一块石头探探路,如果没有陷阱,那么就可以放心地闯关;如果有机关陷阱,那么再想办法应对。


在偏向锁标记之后,如果后续有其他线程来竞争该锁,那就取消原来的偏向锁状态,进入一般的轻量级锁状态。此处的轻量级锁,就是基于 CAS 实现的自旋锁,是属于完全在用户态完成的操作。因此这里面不涉及到内核态用户态的切换,也不涉及到线程的阻塞等待和调度,只是多费了一些CPU而已。然而轻量级锁却能保证更高效地获取到锁 ( 线程1 一释放锁,线程2 就能立即获取到 锁,这就体现了锁的竞争并不激烈 )


但如果在锁冲突的概率较大,锁竞争比较激烈的场景下,那么锁还会进一步地膨胀成重量级锁,如果锁冲突的概率太大了,使用轻量级自旋锁时,就会浪费大量的CPU,在等待的时候是,CPU 空转的。那么此时使用更重量的挂起等待锁,就可以解决这个问题。对于挂起等待锁来说,当锁等待的过程中,会进行释放 CPU,不过代价就是会引入了线程阻塞和调度开销。


综上所述,锁的每个状态、每个策略都有自身的优缺点,实际上没有好坏之分,不同的应用场景下,只有合适的才是最好的。而在我上面所说的锁状态变化的过程中,synchronized 是会自适应锁升级的整个过程。


2. 拓展


synchronized 加锁实际上是一个比较复杂的东西,它的发展也是经历了很多年,都是通过一些计算机大佬一步步的修改出来的,而早期的 synchronized 其实就是一个单纯的互斥锁的封装,到了Java8 时代,其实 synchronized 就已经比较的完善了 ( 1.8 )

而像我们刚刚说的锁能升级过程( 从无锁 => 偏向锁 => 轻量级锁 =>重量级锁 )


那么能不能降级呢?

例如一个场景,平时的时候请求很低,锁冲突的概率很低,以轻量级锁状态工作。突然遇到了一个请求峰值,从而使锁冲突的概率变高了,最后膨胀成了重量级锁。过了一会儿,峰值过去了,请求又少了…这个时候锁冲突的概率又降下去了,此时 synchronized 的加锁机制是否会从重量级锁再降回轻量级锁呢?


我们上面提到 synchronized 的加锁机制实际上和 JVM 版本有关,在 1.8 版本的时候,趋于完善,但在这个版本( 企业广泛使用的版本 1.8 ),即 Oracle 官方的1.8 版本并没有实现锁降级的过程。未来在别的版本是否添加降级机制,有待观察。


3. 锁销除


锁消除,其实就是编译器和 JVM 自行判定一下, 看看当前这个代码是否真的需要加锁。


举个例子:如果当前程序只有一个线程在跑,此时就算写了加锁,编译器也会自动地把锁给去除,不会真的进行加锁。


4. 锁的粒度和锁粗化


锁的粒度:锁的粒度粗细即 synchronized 代码块中包含了多少代码。如果包含的代码相对多,我们认为锁的粒度粗;如果包含的代码相对少,我们认为锁的粒度细。

那么,程序员在写代码的时候,到底是锁的粒度较粗还是较细好,这需要根据场景判断。


如果锁的粒度细,意味着代码持有锁的时间就短,就能更快释放,这样一来,致使其他线程冲突的概率就更小。


而在下图中,锁的粒度粗较好一些,这提高了效率、减小了加锁解锁的开销。


acd113c3d5af438fa35b88128e692b9a.png

锁的粗化:锁的粗化就是上图中对应的代码1 到 代码2 之间的转换,如果程序写出的代码的一段逻辑中如果出现多次加锁解锁,编译器 和 JVM 会自动进行锁的粗化。


5. 总结 synchronized


总的来说,synchronized 的策略是比较复杂的,程序员在编译代码的背后,Java 底层实际做了很多事情,目的为了让程序员哪怕啥都不懂,也不至于写出特别拉跨的程序。JVM 开发者为了 Java 程序员真是操碎了心。


四、Callable 接口



Callable 是一个接口,它相当于把线程封装了一个 " 返回值 "。方便程序员在使用的时候,借助多线程的方式计算结果,它也是用来创建线程的。


我们计算 1 + 2 + … +100 的结果


程序清单3:


public class Test {
    static class Result {
        public int sum = 0;
    }
    public static void main(String[] args) throws InterruptedException {
        Result result = new Result();
        Thread t = new Thread() {
            @Override
            public void run() {
                int sum = 0;
                for (int i = 0; i <= 100; i++) {
                    sum = sum + i;
                }
                result.sum = sum;
            }
        };
        t.start();
        t.join();
        //此处我们希望正在运行的线程能够在主线程获取到
        //为了解决这个问题,就需要引入一个辅助的类
        //我们目标很明确,就是要等到 t 线程运行完了,主线程拿到 sum 即可
        System.out.println(result.sum);
    }
}


输出结果:


13821fdafc594f6d9f5bb8fab50aedfc.png


在程序清单3 中,由于 t 线程与主线程之间是并发执行的,所以我们需要明确:等 t 线程执行全部完了,主线程才能拿到最终的结果!


程序清单4:利用 Callable 接口


import java.util.concurrent.Callable;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.FutureTask;
public class Test {
    public static void main(String[] args) throws ExecutionException, InterruptedException {
        Callable<Integer> callable = new Callable<Integer>() {
            @Override
            public Integer call() throws Exception {
                int sum = 0;
                for (int i = 0; i <= 100; i++) {
                    sum += i;
                }
                return sum;
            }
        };
        //由于 Thread 类不能直接接受一个 callable 的参数,所以就需要一个辅助类来包装一下
        //而 FutureTask 就是这个类
        FutureTask<Integer> futureTask = new FutureTask<>(callable);
        Thread t = new Thread(futureTask);
        t.start();
        //尝试在主线程获取结果,如果 Callable 中的代码还没全部执行完,就不会返回
        //在主线程中调用 futureTask.get() 能够阻塞等待,直到正在执行的线程运算结果完毕
        Integer result = futureTask.get();
        System.out.println(result);
    }
}


输出结果:


79e9ab25b9e64d9586065926b523191d.png


总结:


Callable 和 Runnable 是同一级别的,但却是相对的,它在于都是描述一个 “任务”。

Callable 接口描述的是带有返回值的任务,而 Runnable 接口描述的是不带返回值的任务。


Callable 接口通常需要搭配 FutureTask 类来使用,FutureTask 用来保存 Callable 的返回结果,因为 Callable 往往是在另一个线程中执行的,啥时候执行完并不确定。而 FutureTask 就可以负责等待整个当前线程执行的过程,最后通过 get 方法返回执行完的结果即可。


理解 FutureTask


举个例子。假设你和朋友去吃麻辣烫,当餐点好后,后厨就开始做了,同时前台会给你一张 " 小票 ",这个小票就是FutureTask,后面我们可以随时凭这张小票去查看自己的这份麻辣烫做出来了没。


五、ReentrantLock 类



ReentrantLock :可重入锁

re-entrant a. 可重入的


程序清单5:


在程序清单5 中,这是我们之前使用多线程,让 count 自增的经典案例,t1 线程和 t2 线程各自增加 5w 次,我们尝试利用 ReentrantLock 类来加锁。


import java.util.concurrent.locks.ReentrantLock;
public class Test {
    static class Counter {
        public int count = 0;
        public ReentrantLock locker = new ReentrantLock();
        //让 count 变量自增
        public void increase() {
            locker.lock();
            count++;
            locker.unlock();
        }
    }
    static Counter counter = new Counter();
    public static void main(String[] args) {
        //线程1 自增 5w 次
        Thread t1 = new Thread() {
            @Override
            public void run() {
                for (int i = 0; i < 50000; i++) {
                    counter.increase();
                }
            }
        };
        //线程2 自增 5w 次
        Thread t2 = new Thread() {
            @Override
            public void run() {
                for (int i = 0; i < 50000; i++) {
                    counter.increase();
                }
            }
        };
        t1.start();
        t2.start();
        try {
            t1.join();
            t2.join();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println(counter.count);
    }
}


输出结果:


ffd2400fec864a09ade35afb53de126a.png


ReentrantLock 类提供了加锁功能,它与 synchronized 加锁机制有联系,也有区别。


ReentrantLock 把加锁和解锁操作拆分开了,这种风格的代码,是常见写法,很多编程语言都是这样的;而 synchronized 是较为流行的写法,使用起来也较为简单。


ReentrantLock 与 synchronized 加锁的区别与联系


① synchronized 是一个关键字,是 JVM 内部实现的 ( 大概率是基于 C++ 实现 )


ReentrantLock 是标准库的一个类,它是在 JVM 外实现的 ( 基于 Java 实现 )


② ReentrantLock 把加锁和解锁拆成两个方法,这使得代码更加灵活,比如把加锁和解锁的代码分别放到两个方法中,而 synchronized 却做不到,因为被 synchronized 包裹的代码,出了代码末尾,才算被解锁。


③ ReentrantLock 类提供了 synchronized 内部没有的机制。它除了 lock 加锁 和 unlock 解锁 之外,还提供了 tryLock 方法。对于 lock 方法来说,如果尝试加锁失败,就会阻塞等待;对于 tryLock 方法来说,如果尝试加锁失败,就直接返回出错,不会阻塞等待,所以这就降低了浪费 CPU 的资源。


④ synchronized 是一个非公平锁,而 ReentrantLock 既支持公平锁,也支持非公平锁。


fe4f391193724c3d968f7786f3622df0.png


⑤ ReentrantLock 提供了比 synchronized 更强大的等待唤醒机制,synchronized 是搭配 wait 和 notify 方法,而 ReentrantLock 则是搭配了另外一个 Condition 类来完成等待唤醒。notify

方法是随机唤醒一个线程,而 Condition 类可以显示指定唤醒哪个等待的线程。


综上所述,大部分情况下,我们依然使用 synchronized 关键字来加锁解锁,这更熟悉,而且它用起来也比较方便;但某些特定场景下,我们就需要使用 ReentrantLock 类的一些加锁机制下的功能。


六、信号量



信号量,用来表示 " 可用资源的个数 ",本质上就是一个计数器。


举个例子:我们可以把信号量想象成是停车场的空车位,当前有车位 100 个,表示有 100 个可用资源。当有车开进去的时候,就相当于申请一个可用资源,可用车位就 -1 ( 这个操作对应信号量的 P 操作 ),当有车开出来的时候,就相当于释放一个可用资源,可用车位就 +1 ( 这个操作对应信号量的 V 操作 )。如果计数器的值已经为 0 了,还尝试申请资源,就会阻塞等待,直到有其他线程释放资源,这就相当于车库的车都停满了,门卫把停车场的升降杆降下来了,不让你进去。那么信号量的值不会为负数。


Semaphore 的 PV 操作中的加减计数器操作都是原子的,可以在多线程环境下直接使用


P 操作对应 acquire 方法,表示申请资源。
V 操作对应 release 方法,表示释放资源。


程序清单6:


import java.util.concurrent.Semaphore;
public class Test {
    public static void main(String[] args) {
        //创建 Semaphore 的实例, 初始化为 3, 表示有 3 个可用资源
        //想象成停车场只有三个空位子
        Semaphore semaphore = new Semaphore(3);
        Runnable runnable = new Runnable() {
            @Override
            public void run() {
                try {
                    //1. 先尝试申请资源
                    System.out.println("尝试申请资源");
                    semaphore.acquire();
                    //2. 申请到了之后,睡眠 1 秒
                    System.out.println("申请成功!");
                    Thread.sleep(1000);
                    //3. 再释放资源
                    semaphore.release();
                    System.out.println("完毕");
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        };
        //创建 6个线程,让这 6个线程分别尝试获取资源
        for (int i = 0; i < 6; i++) {
            Thread t = new Thread(runnable);
            t.start();
        }
    }
}


输出结果:

6afe7bcafd024c4994ebd1c4e226cce1.png


在程序清单6 中,我们创建信号量的值为3,在输出结果中我们可以看到,只有当线程释放资源的时候,其他线程才能申请到资源。这就好比停车,把它想象成停车场只有三个位置,而现在有六辆车来争夺资源,也就是争夺空车位,而当前三辆车争夺到这三个位置之后,其他的车就要阻塞等待,直到车主将车开出停车场,才有空位置。


信号量对比 synchronized


在使用 synchronized 的时候,我们都知道,当为一个代码块上锁的时候,所有的线程都得同时竞争这把锁,之后我们就能控制某个线程将这个代码块全部执行完,这个线程在还未执行完之前,其他线程就得等待,相当于只有一个空车位。显然,锁本身只能控制一个资源。


而信号量却可以控制多个资源,它可以让多个线程同时争夺多个资源,而资源数可以为我们所控制,所以说,信号量其实就是 synchronized 的进阶版本。


那么当我们将信号量的可用资源数换成1 了,那它实际上与加锁解锁的机制没什么区别,同时这里的信号量也被成为 " 二元信号量 "。这就相当于:多个线程竞争一把锁,即多个车竞争一个空车位。

目录
相关文章
|
6月前
|
缓存 安全 Java
保障线程安全性:构建可靠的多线程应用
保障线程安全性:构建可靠的多线程应用
|
4天前
|
监控 安全 定位技术
《C++新特性:为多线程数据竞争检测与预防保驾护航》
多线程编程是提升软件性能的关键,但数据竞争问题却是一大挑战。C++新特性如增强的原子类型和完善的内存模型,为检测和预防数据竞争提供了有力支持。这些改进不仅提高了程序的可靠性,还提升了开发效率,使多线程编程更加安全高效。
47 19
|
4月前
|
安全 数据安全/隐私保护 数据中心
Python并发编程大挑战:线程安全VS进程隔离,你的选择影响深远!
【7月更文挑战第9天】Python并发:线程共享内存,高效但需处理线程安全(GIL限制并发),适合IO密集型;进程独立内存,安全但通信复杂,适合CPU密集型。使用`threading.Lock`保证线程安全,`multiprocessing.Queue`实现进程间通信。选择取决于任务性质和性能需求。
92 1
|
1月前
|
安全 Java 数据库连接
Python多线程编程:竞争问题的解析与应对策略【2】
Python多线程编程:竞争问题的解析与应对策略【2】
23 0
|
1月前
|
安全 Java 数据库连接
Python多线程编程:竞争问题的解析与应对策略
Python多线程编程:竞争问题的解析与应对策略
22 0
|
1月前
|
设计模式 监控 安全
Python多线程编程:特性、挑战与最佳实践
Python多线程编程:特性、挑战与最佳实践
38 0
|
4月前
|
安全 Java 调度
多线程编程的挑战与解决方案
多线程编程的挑战与解决方案
|
4月前
|
Java 调度
Java并发编程中的常见陷阱及解决方案
Java并发编程中的常见陷阱及解决方案
|
5月前
|
缓存 并行计算 安全
【并发编程系列一】并发编年史:线程的双刃剑——从优势到风险的全面解析
【并发编程系列一】并发编年史:线程的双刃剑——从优势到风险的全面解析
|
5月前
|
安全 程序员
多线程的6个综合练习
多线程的6个综合练习
34 0