【Java ee 初阶】多线程(8)

发布于:2025-05-09 ⋅ 阅读:(19) ⋅ 点赞:(0)

Synchronized优化:

一、锁升级

锁升级时一个自适应的过程,自适应的过程如下:

在Java编程中,有一部分的人不一定能正确地使用锁,因此,Java的设计者为了让大家使用锁的门槛更低,就在synchronized中引入了很多优化策略。

*首先,根据前面的学习,我们已经初步了解了自旋锁和重量级锁,那么图中的偏向锁是什么?

偏向锁,不是真正加锁,而只是做个标记,如果整个过程中,没有其他线程来竞争这个锁,偏向锁状态就始终保持,直到最终解锁。但是,如果在过程中,遇到其他线程也尝试来竞争这个锁,就可以在其他线程拿到锁之前,抢先获取这个锁。这样子仍然可以使其他线程产生阻塞,保证线程的安全。

锁升级的过程:

1.偏向锁->轻量级锁:说明该线程上发生了锁竞争

2.轻量级锁->偏向锁:竞争更加激烈

那我们如何去了解竞争的激烈程度呢?——JVM内部,统计了这个锁上面有多少个线程在等待获取,这一切都取决于JVM的实现。

而我们作为程序员,关心的是策略,而不是参数,因为参数都是可以调整的,但是策略是不变的。

锁升级,对于当前主流的JVM实现来说,是“不可逆”的,一旦升级了,就无法回头降级了

二、锁消除

有些代码,那你写了加锁,但是在JVM执行的时候会发现,这个地方没必要加锁,因此JVM就会自动把锁给去除掉

例如:我们知道,StringBuilder不带有synchronized,StringBuffer带有synchronized,因此可能有人在单线程环境下就使用StringBuffer,此时这个锁只在一个线程中,因此会被JVM给优化掉。

JVM的优化是一方面,咱们作为程序员,也不能够完全摆烂。

三、锁粗化

首先我们先了解一个概念——锁的粒度。加锁和解锁范围中代码越多,锁的粒度就越粗,反之锁的粒度就越细

如图我们可知这两段代码,上面那一段代码的粒度更粗,下面代码的粒度更细。

获取到一次锁,可能是一件不太容易的事情。因此有的时候,JVM会进行锁粗化,将加锁解锁的次数减少,以此提高效率。synchronized关键字,作为Java的关键字,底层实现是在JVM内部完成的,不是通过Java来实现的,而是通过C++来实现的。

总结:synchronized优化主要体现在:

1.锁升级

2.锁消除

3.锁粗化

四、CAS

CAS,全称compare and swap,比较和交换。

这是一串cas的伪代码(不是严格符合语法等待,知识用来描述一下逻辑)

address:是一个内存地址

expectValue,swapValue:是CPU寄存器

这段代码实现的内容是:拿着内存中的值,和寄存器1的值进行比较,如果二者相等,就把内存和寄存器2的值进行交换。一般来说,只关心内存中值的变化,而不关心寄存器2中发生了什么变化。上述的交换,也可以近似理解成赋值。

注意,上述所有的逻辑,都是通过“一个CPU指令”完成的。一条指令,意味着这是原子的!!没有线程安全的问题。

CPU的特殊指令完成了上述的所有操作—>操作系统封装了这个指令,形成了一个系统的API—>Java中又封装了操作系统的API,由unsafe包进行提供,这里提供的操作,比较底层,可能不安全。

CAS的典型应用

1.实现原子类

可以将原来的三步打包成原子的,通过AtomicInteger,原子的进行++ -- 等操作

例如:

package Thread;

public class demo1 {

    public static int count = 0;
public static void main(String[] args) throws InterruptedException {
    
    

    Thread t1 = new Thread(()->{
        for(int i=0;i<5000;i++){
            count++;
        }
    });

    Thread t2 = new Thread(()->{
        for(int i=0;i<5000;i++){
            count++;
        }
    });
    t1.start();
    t2.start();
    t1.join();
    t2.join();

    System.out.println(count);
}

}

输出结果:

出现了运算符重载的问题!

什么是运算符重载:运算符重载就是让已有的运算符针对不同的类,也能够有对应的效果,Java并不支持。运算符重载的好处,就是可以用使代码的一致性更高,比如,定义一个类,“复数”通过运算符重载,就可以使得复数类和其他的数字类型相似,都可以进行加减乘除。定义一个矩阵类也可以通过运算符重载。

Java中认为,上述写法,容易被滥用,因此Java并不支持。

package Thread;

import java.util.concurrent.atomic.AtomicInteger;

public class demo1 {

    private static AtomicInteger count = new AtomicInteger(0);
    
public static void main(String[] args) throws InterruptedException {
    Thread t1 = new Thread(()->{
        for(int i=0;i<5000;i++){
            //下列这些操作,都是原子的,不加锁也能保持线程安全
            count.getAndIncrement();//count++
            //count.incrementAndGet();//++count
            //count.getAndIncrement();//count++
            //count.getAndDecrement();//--count
            //count.GetAndDecrement();//count--
            //count.getAndAdd(10);//count+=10;
        }
    });

    Thread t2 = new Thread(()->{
        for(int i=0;i<5000;i++){
            count.getAndIncrement();//count++
        }
    });
    t1.start();
    t2.start();
    t1.join();
    t2.join();

    System.out.println(count);
}

}

此时的输出:

这样的操作,不仅线程安全,而且效率更高,不涉及到阻塞。因此在实际开发中,如果有这种计数的需求,优先考虑原子类,而不是自己去加锁。

划红线的部分,就是JVM封装过的,比上述谈到的CAS还要更加复杂一点,但是他们的本质是一样的。

伪代码实现:

CAS如果一样,就说明在这两者之前没有其他线程修改value内存,此时就可以安全的对value进行修改了。如果CAS发现有其他线程插队,就会进入循环,重新load。此处的oldValue是寄存器,而不是变量。C语言曾经能够定义寄存器变量,但是后来也废除的,也就只有汇编能够直接操作寄存器了,和之前传统的++相比,在真正进行修改之前,又做了判定。

2.实现自旋锁

CAS实现自旋锁的伪代码

如果owner为null,解锁状态,否则就会保存持有锁的线程的引用。如果锁已经被占用了,这个循环,就会快速地反复地循环。循环体中是没有任何sleep。这是一种消耗cpu,换来尽快地加锁速度。但是,如果锁竞争很激烈,大量的线程都会这样自旋,就会消耗非常多的cpu,那么cpu就负担不起了,锁释放之后,这些线程还是要竞争,还是意味着大量的线程是无法第一时间拿到锁的。

下面的赋值操作天然就是原子的,判定-赋值(check and set),典型的非原子的操作。

CAS的ABA问题

通过CAS来判定,当前load到寄存器的内容和内存的内容是否一致,如果一致,就认为没有其他线程修改过这个变量,接下来本线程的修改就是安全的。

然而,这里面存在着一个缺陷:可能出现这种情况:另一个线程又把内存的值从A改成B,又从B改回了A,此时CAS是感知不到的,仍然会认为没有其他线程修改过的。

ABA问题,通常情况下都是没事的,即使其他线程真的修改了,由于又修改回了原来的值,所以ABA现象不一定给程序引起BUG,但是如果遇到特别极端的场景,那还是有可能的,下面是一个非常极端的例子:

上述场景下,由于ABA问题,导致了重复扣款。

那么,针对ABA问题,我们应该如何解决呢?上述问题之所以出现ABA问题,是因为他针对余额的修改可能加,也可能减,如果改变成“只能加,不能减”,那么就可以解决上述问题。因此,我们引入版本号,约定每次修改,都需要对版本号+1,并且每次CAS比较的时候都是比较版本号是否相同(相同的话,进行版本号+1 和 余额修改)

我们可以定义一个类,包含版本号和余额,如下:

五、Callable接口

callable接口,类似于Runnable,也是属于JUC(java.util.concurrent)这个包的。call自定义返回值,run返回值是void。

接下来让我们利用Callable接口创建一个线程,并且通过这个线程计算1+2+3+...+100

package Thread;

import java.util.concurrent.Callable;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.FutureTask;

public class demo2 {
public static void main(String[] args) throws InterruptedException, ExecutionException {
    //callable 带有泛型参数 泛型参数就是返回值类型
    Callable<Integer> callable = new Callable<Integer>() {
        @Override
        public Integer call() throws Exception {
            int result = 0;
            for(int i=0;i<=100;i++){
                result+=i;
            };
            return result;
        };
    };
    FutureTask<Integer> futureTask = new FutureTask<>(callable);
    Thread t = new Thread(futureTask);
    t.start();
    //get会阻塞等待线程执行完毕,拿到返回值
    System.out.println(futureTask.get());
}
}

创建线程的方式:1.继承Thread 2.实现Runnable 3.基于lambda(本质还是Runnable)4.实现Callable 5.基于线程池

六、ReentranLock

ReentrantLock是一把比较传统的锁,在synchronized还不成熟的时候,这个锁就是进行多线程编程加锁的主要方案

package Thread;

import java.util.concurrent.locks.ReentrantLock;

public class demo3 {

    private static int count = 0;

    public static void main(String[] args) throws InterruptedException {
        
        ReentrantLock locker = new ReentrantLock();
        
        Thread t1 = new Thread(()->{
           
            for(int i=0;i<5000;i++){
                //加锁
                locker.lock();
                count++;
                //解锁
                locker.unlock();
            }
        });

        Thread t2 = new Thread(()->{
            for(int i=0;i<5000;i++){
                locker.lock();
                count++;
                locker.unlock();
            }   
        });

        t1.start();
        t2.start();
        t1.join();
        t2.join();

        System.out.println(count);
    }


}

这种写法,容易把unlock遗漏

换成这种写法,不太美观

区别与联系

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

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

2.synchronized使用的时候不需要手动释放锁

  ReentantLock使用的时候需要手动释放,使用的时候更加灵活,但是也容易遗漏。

3.synchronized是非公平锁,ReentrantLock默认是非公平锁,可以通过构造方法传入一个true开启公平锁模式

4.synchronized在申请锁失败的时候会死等,ReentrantLock可以通过trylock的方式等待一段时间就放弃(这是synchronized不具备的特点,trylock加锁失败的时候会直接放弃)

5.ReentrantLock拥有更强大的唤醒机制,synchronized是通过Object的wait /notify实现 等待-唤醒。每次唤醒的是一个随机等待的线程 ReentrantLock搭配Condition类来实现等待-唤醒,九二一更加精确控制唤醒某个指定的线程


网站公告

今日签到

点亮在社区的每一天
去签到