Java中的线程安全与线程同步

1.为什么需要线程同步

什么是线程安全:指在被多个线程访问时,程序可以持续进行正确的处理。

1.1.线程安全问题

案例:通过抢优惠例子说明线程安全问题

image

public class Demo1 {

    public static void main(String[] args) {
        // 简单模拟20人抢优惠
        for(int i=0;i<20;i++){
            new Thread(new ThreadDemo()).start();
        }
    }

}
// 前十位可以获取优惠,凭号码兑换优惠class ThreadDemo implements Runnable{
    private static Integer num = 10;
    @Override
    public void run() {
        try {
            Thread.sleep(10);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        if(num<=0){
            System.out.println("已被抢完,下次再来");
            return;
        }
        System.out.println(Thread.currentThread().getName()+"用户抢到的号码:"+num--);
    }
}

执行结果:出现的问题

同一个优惠号码可能被多次获取到;

优惠号码可能获取到0 和负数,类似超买超卖

并发访问线程不安全的共享变量时,会出现如上的常见问题。

避免问题的产生

共享变量设计为不可变的(final)

编程过程不修改共享变量(不修改)

将对象设计为无状态的(无状态)

在修改共享变量时使用线程同步(通过锁实现)

2.怎么实现线程同步

线程同步是指程序中用于控制不同线程间操作发生相对顺序的机制。

2.1.使用volatile关键字

volatile是轻量级的synchronized,对于共享变量可以通过volatile关键字来实现线程同步。

共享变量处理情况:总线先从主内存拷贝共享变量到线程私有的工作内存,再交由处理器进行处理,处理完成后再从工作内存将运算结果回写主内存。工作内存起到临时缓存数据和指令的作用、并且线程私有,这就会存在缓存不一致问题。

实现原理:有volatile变量修饰的共享变量进行写操作的时候会多出一行lock 前缀指令的汇编代码,lock前缀指令会直接锁缓存行,起到内存屏障的效果,并使处理器立即执行缓存回写到主内存的操作,并且导致其他处理器的缓存失效,需要重新从主内存获取最新值。

附上一张图便于理解

image

Java线程需要由操作系统内核线程调度器进行调度,并不是直接访问处理器资源,图中仅展示关键的几个点。

怎么输出汇编指令

windows:下载hsdis-amd64.dll(下载链接在文末),并放在目录 jdk1.8.0_181\jre\bin\server

在idea 工具中配置VM:-Xcomp -XX:+UnlockDiagnosticVMOptions -XX:+PrintAssembly -XX:CompileCommand=compileonly,*TestVolatile.main

// 测试代码public class TestVolatile {
    private static volatile int num = 0;

    public static void main(String[] args) {
        num--;
        System.out.println("结果为:" + num);
    }
}

执行结果:lock add dword ptr [rsp]

0x000000000320606d: lock add dword ptr [rsp],0h ;*putstatic num ; - com.yty.concurrent.synchronizeddemo.TestVolatile::main@5 (line 7)

通过缓存锁定和缓存一致性协议实现可见性,确保多线程程序读写共享变量的时候,每个CPU看到的都是最新值。

MESI 高速缓存一致性协议,处理器使用嗅探技术保证缓存、主内存和其他处理器缓存的数据一致。还有其他的缓存一致性协议,比如:AMD的MOESI协议、Intel的MOSIF协议。

MESI 分别表示:

  • M(Modify):修改
  • E(Exclusive):独占、互斥
  • S(Shared):共享
  • I(Invalid):无效

volatile 关键字的另一个作用是禁止编译器或处理器对指令进行重排序优化

什么是重排序:重排序是指编译器和处理器为了优化程序性能而对指令序列进行重新排序的一种手段。说白了,如果在编译或运行期间发生重排序,那么程序就可能不按照编写的顺序执行,会出现意料之外的执行结果。

编译器和处理器不会对存在数据依赖关系的操作做重排序,一般重排序可能发生在没有数据依赖关系的指令之中。就算数据之间没有依赖关系,在多线程场景中若发生指令重排序优化,依然存在影响到最终执行结果的情况。当多线程场景下存在这种情况时,就需要使用volatile关键字等其他手段去禁止编译器和处理器对指令重排序优化。

实现原理是在编译器在生成字节码时,会在指令序列中插入内存屏障禁止在内存屏障前后的指令执行重排序优化。

回来到一开始的案例问题,给线程类的共享变量 private static Integer num = 10; 加上volatile关键字能实现线程安全吗?

private static volatile Integer num = 10;

答案是:不能

原因是:指令【num--】看似一条指令,其实分成三步执行:先获取、再计算、后保存,所以自减和自增都不是原子性操作,而volatile 无法确保其原子性操作,所以使用volatile关键字无法确保该情况下的线程安全,需要使用锁来实现原子性操作。

2.2.使用synchronized关键字

synchronized块是Java提供的一种原子性内置锁,Java中的每个对象都可以把它当作一个同步锁来使用,也称为监视器锁。synchronized 同步代码块中的操作被看为原子性操作。同步锁是一种排它锁、独占锁、可重入锁,当一个线程获取这个锁后,其他线程必须等待该线程释放锁后才能获取该锁,并支持锁释放后可以再次获取锁。

使用方式

private Integer num = 1;
private static Integer num2 = 1;

public synchronized void test1(){
    System.out.println("普通方法" + num++);
}
public static synchronized void test2(){
    System.out.println("静态方法" + num2++);
}

public void test1_1(){
    synchronized (this){
        System.out.println("锁当前对象"+ num++);
    }
    synchronized (TestSynchronized.class){
        System.out.println("锁当前类" + num++);
    }
    synchronized (num){
        System.out.println("锁指定变量" + num++);
    }
}

synchronized 关键字加在普通方法时,是给当前实例对象上锁;

synchronized 关键字加在静态方法时,是给当前Class类对象上锁;

synchronized 关键字加在同步代码块时,是给括号里配置的对象上锁。

注意:这个Class类对象指的是每个类在类加载过程中生成的Class类。

原理简析

在synchronized的同步代码块中,入口处执行了monitorenter,在其出口执行了monitorexit,虚拟机中的每个Object实例都有一个monitor(监视器锁);而同步方法通过字节码flags 标记该方法为ACC_SYNCHRONIZED,表明执行该方法时需要获取到监视器锁才可以执行。两者的本质都是对一个对象的监视器(monitor)的获取和释放。

可以使用javap -c -v xxx.class 查看字节码信息

同步代码块

image

同步方法

image

关于一开始的例子,可以写成👇

public class Demo1 {

    public static void main(String[] args) {
        // 简单模拟20人抢优惠
        for(int i=0;i<20;i++){
            new Thread(new ThreadDemo()).start();
        }
    }

}
// 前十位可以获取优惠,凭号码兑换优惠class ThreadDemo implements Runnable{
    private static Integer num = 10;
    @Override
    public void run() {
        try {
            Thread.sleep(10);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        synchronized (num){
            if(num<=0){
                System.out.println("已被抢完,下次再来");
                return;
            }
            System.out.println(Thread.currentThread().getName()+"用户抢到的号码:"+num--);
        }
    }
}

或者改为

注意:num 变量是static变量,是属于类的变量,需要锁住变量对象或Class类,单独或组合使用synchronized (this) 和volatile都无法确保其线程安全,这个可自行验证。

案例:对象单例实现

public class SingletonDemo {
    private static volatile SingletonDemo singletonDemo;
    public SingletonDemo(){
    }
    public static SingletonDemo getInstance(){
        if(singletonDemo==null)
            synchronized (SingletonDemo.class){
                if (singletonDemo==null)
                    singletonDemo = new SingletonDemo();
            }
        return singletonDemo;
    }
}
// 测试class Demo{
    public static void main(String[] args) {
        Demo demo = new Demo();
        for(int i=0;i<10000;i++){
            demo.test();
        }
    }
    public void test(){
        new Thread(()->{
            SingletonDemo instance = SingletonDemo.getInstance();
            System.out.println(Thread.currentThread().getName()+"="+instance);
        }).start();
    }
}

说明:volatile 和 synchronized实现双重锁校验。synchronized 起到指令执行的原子性和同一时间只能单个线程执行;synchronized和volatile都起到内存可见性保证;volatile起到禁止指令重排序,指令重排序导致线程不安全的可能性较小,但存在可能发生。

除了简单易用的synchronized 同步锁之外,还有其他更灵活的锁🔒。

篇幅原因,将在下一篇讲述关于Java中的锁:

image

hsdis-amd64.dll 的下载链接:https://pan.baidu.com/s/1LZqJb7WaUlh9VVGelfAgcw?pwd=yyds

参考资料:

《Java并发编程的艺术》

《Java并发编程之美》

image

Java线程状态(生命周期)--一篇入魂

自己编写平滑加权轮询算法,实现反向代理集群服务的平滑分配

Java实现平滑加权轮询算法--降权和提权

Java实现负载均衡算法--轮询和加权轮询

Java全栈学习路线、学习资源和面试题一条龙

更多优质文章,请关注WX公众号:Java全栈布道师

image

posted @ 2022-06-23 14:16  渊渟岳  阅读(148)  评论(0编辑  收藏  举报