为什么单例模式需要double-check , volatile告诉你必要性(二)

1,915 阅读3分钟

「这是我参与2022首次更文挑战的第6天,活动详情查看:2022首次更文挑战」

synchronized

public class SimpleTest {
    public static void main(String[] args) {
        synchronized (SimpleTest.class) {
            System.out.println("hello world");
        }
    }
}
  • 上面是个很简单的synchronized的使用。我们看看对应的字节码部分

image-20211210092312546.png

  • synchronized实际上字节码层面就是MONITORENTERMONITOREXIT ; 值得注意的是在MONITOR 指令之前我们都一个指令在操作栈上数据。这就是我们synchronized括号后面的内容

image-20211210092826347.png

  • 稍微改动我们就能够发现,操作对象就不同了。从栈的角度我们也好理解上锁的概念了吧。

  • 往往提到并发synchronized这个关键字是挥之不去的,上面我们攻克了volatile这个关键字。剩下的就是synchronized了。首先我们先来看看概念

  • synchronized作用就是上锁,在多线程开发中被synchronized修饰的代码同一时间内只会有一个线程拥有,并且被修饰的代码是无法被打断的。保证了多线程下的串行化执行。这里我们可以理解成原子性

  • 另外synchronizedLock的区别这里我们也不做展开。我们大概总结下就是前者是Java语言关键字,后者是java 类;另外前者原生锁后者需要自己上锁和解锁。

  • synchronized关键字修饰的对象有以下几种情况

修饰对象作用
代码块被修饰代码块是同步代码块。代码块锁范围取决于修饰对象
普通方法同步方法,相当于锁住调用该方法的对象
静态方法同步方法,相当于锁住调用该方法的Class对象
修饰类锁住Class对象
修饰对象锁住java对象
  • 上面的总结还是比较抽象的。其实synchronized只有两种情况,锁的范围不同
  • 一个是锁住Class对象。一个是锁住java对象。

锁java对象

class S1{
    int age=2;
}
public class SynchronizedTest {
    static int index = 0;
    public static void main(String[] args) {
        List<Thread> threadList = new ArrayList<Thread>();
        for (int i = 0; i < 10000; i++) {
            final S1 s1 = new S1();
            Thread thread = new Thread(new Runnable() {
                public void run() {
                    synchronized (s1) {
                        index++;
                    }
                }
            });
            thread.start();
            threadList.add(thread);
        }
        for (Thread thread : threadList) {
            try {
                thread.join();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
        System.out.println(index);
    }
}
  • 上面代码创建10000个线程进行递增操作。每个线程递增操作本来是不安全的,但是我们加上了synchronized表示index++不会被中断,始终只会有一个线程在执行那么最终结果是index=10000 ; 运行下来能够发现index=10000的概率很大,但是还是有部分情况下index<10000 ;
  • 还记得我们在volatile中的案列吗?这里你感觉到奇怪吗?index变量并没有加上volatile修饰,但是为什么很大概率index=10000;不是说线程之间内存不可见吗?index=10000不就是说明线程修改了会被其他线程同步最新值的。毕竟我们代码中线程是并发执行的。这里需要指出的是synchronized也具有线程可见性。synchronized的作用就是保证始终只有一个线程在同步。
  • 那么为什么index<10000还是会发生呢?问题出现在s1上。synchronized(s1)这段代码表示锁s1这个对象。而我们每次都是重新生成s1对象相当于我们每次锁都不一样。s1(0x a f f)这个对象的锁对于s1(0x100ff)是无效的。所以导致index<10000

锁Class


class S1{
    int age=2;
}
public class SynchronizedTest {
    static int index = 0;
    public static void main(String[] args) {
        List<Thread> threadList = new ArrayList<Thread>();
        for (int i = 0; i < 10000; i++) {
            final S1 s1 = new S1();
            Thread thread = new Thread(new Runnable() {
                public void run() {
                    synchronized (S1.class) {
                        index++;
                    }
                }
            });
            thread.start();
            threadList.add(thread);
        }
        for (Thread thread : threadList) {
            try {
                thread.join();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
        System.out.println(index);
    }
}
  • 代码基本没有变化,只是锁的是S1.class这个类信息。这次每个线程进来他们的信仰来自于同一个地方。相当于第一次案列是每个锁听命于不同的领导。第二个案例中每个锁都听命于同一个领导。那么前一个锁会影响到后来的锁导致后锁上锁失败就会在队列等待。
  • 而修饰普通方法就相当于锁对象;修饰静态方法相当于锁Class。

DCL和volatile

  • DCL(Double Check Lock)指的是单列模式,相信很多人的单列模式都是懒汉饿汉的区别。我也是推荐大家使用饿汉是单列模式,因为这种模式牺牲了内存带来的好处是在高并发场景下安全可靠。为什么单列模式和volatile能扯上关系呢?这就是懒汉的问题
public class OnFactory {
    private static OnFactory onFactory;

    public static OnFactory getInstance() {
        if (null == onFactory) {
            onFactory = new OnFactory();
        }
        return onFactory;
    }
}
  • 上面的单列模式应该是很常见的懒汉式单列模式了吧。下面我们去获取下看看每次获取到的对象是否是一样的吧。

public class FactoryTest {
    public static void main(String[] args) {
        Set<OnFactory> sets = new HashSet<OnFactory>();
        for (int i = 0; i < 1000; i++) {
            sets.add(OnFactory.getInstance());
        }
        System.out.println(sets.size());
    }
}
  • 最终输出的大小是1 , 说明我们的单列模式没有问题。如果你也认为没有问题那就天真了。上面是一个线程当然没有问题。但是当多线程访问的时候就会出现问题了。
  • 问题是当多个线程同时执行到判断逻辑,此时onFactory==null,所以这些线程都会去创建对象。导致单列并不单列了。

public class OnFactory {
    private static volatile OnFactory onFactory;

    public static OnFactory getInstance() {
        if (null == onFactory) {
            synchronized (OnFactory.class) {
                if (null == onFactory) {
                    onFactory = new OnFactory();
                }
            }
        }
        return onFactory;
    }
}
  • 上述就是将synchronized 和volatile 结合使用实现的double check ,双重检查。

    疑问

  • 看了上面的double check 有没有这样一个疑问?貌似在double check中volatile 好像并没有那么重要。因为synchronized本身也具有内存可见性,这样保证了线程之间变量同步并且因为加锁的缘故数据赋值都是串行的操作。为什么还要加上volatile 。 那么就剩下volatile剩下一个特性---禁止指令重排序

  • 那么上面的double check哪里会有指令重排序?new OnFactory并不是原子性的,我们看下编译后的字节码

image-20211208170414649.png

  • 通过字节码分析我们能够看到一个new 对象实际上对应的是四个指令。
指令作用
NEW创建一个对象,并将其引用值压入栈顶
DUP复制栈顶数值并将复制值压入栈顶(取出来才能复制,复制完需要还回去)
INVOKESPECIAL调用超类构造方法,实例初始化方法,私有方法
ASTORE将栈顶引用型数值存入指定本地变量
  • 也就是说我们new OnFactory一行代码实际上有四个指令;如果我们不加volatile那么就会发生指令重排序。我们看看NEW指令是下面是下面三个指令的根本,所以NEW肯定不会重排序。剩下三个一个是记录引用位置,一个是将引用位置的内存初始化数据,一个是将引用地址存到本地变量表中。这个就好像一个人为你去做姓名登记,一个为你装饰打扮,一个将你的照片清洗出来。这三个完全没有先后顺序所以会发生指令重排序。

image-20211208172835321.png

  • 当发生指令重排序时,其他线程就很有可能获取到对象,但是该对象还没有初始化,这个时候恰好我们需要使用到未初始化数据,则会报错。但是这里有个细节就是CPU执行速度特别快,按照上面的代码我们只是创建一个对象这种场景时很难模拟出指令重排序的现象的,为了方便我们模拟我在在创建对象过程之前先休眠一下,这样CPU就会很容易指令重排序。我们在不加volatile 就很容易出现半成品对象
public class OnFactory {
    private Integer age=null;
    private static OnFactory onFactory;

    public Integer getAge() {
        return age;
    }

    public static void setInstance() {
        onFactory = null;
    }
    public static OnFactory getInstance() {
        if (null == onFactory) {
            synchronized (OnFactory.class) {
                if (null == onFactory) {
                    //System.out.println("init......");
                    try {
                        Thread.sleep(1);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                    onFactory = new OnFactory();
                    onFactory.age=8;
                }
            }
        }
        return onFactory;
    }
}

public class FactoryTest {
    public static void main(String[] args) {
        while (true){
            OnFactory.setInstance();
            List<Thread> threadList = new ArrayList<Thread>();
            final Map<OnFactory,Object> map = new Hashtable<OnFactory,Object>();
            for (int i = 0; i < 1000; i++) {
                Thread thread = new Thread(new Runnable() {
                    public void run() {
                        final OnFactory instance = OnFactory.getInstance();
//                    System.out.println(instance);
                        map.put(instance, instance.getAge().toString());
                    }
                });
                thread.start();
                threadList.add(thread);
            }
            for (Thread thread : threadList) {
                try {
                    thread.join();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
            if (map.size() > 1) {
                System.out.println(map.size());
            }
        }
    }
}
  • 上面是个错误的改造版,目的就是模拟出DCL不加volatile 的危害

image-20211209114507799.png

  • 还有一点我在使用M1版JDK配合macbook pro 2021电脑使用相同的代码没有测试出该效果。不知道是不是zulu jdk的问题还是mac电脑内存太快的原因,还请大佬们指点一下。

总结

  • 总而言之java高并发编程并不是我们想象的那么简单。我们仅从理论上分析了如何避免并发带来的问题,但是实际操作中我们往往不可能仅仅使用synchronized来锁住资源。就算我们分段锁也还是造成系统瓶颈,同时在分布式中我们还有分布式锁来替代synchronized , 关于分布式锁我之前介绍了redis 、zookeeper、mysql来实现的利弊。
  • 回到主题synchronized作为Java原生支持的锁,他的最大的优点是不需要我们手动管理上锁和释放锁都是自动行为。相比Lock简单很多。volatile平时开发中很少使用到的关键子。但是并发中不可忽视的一个对象,保证内存可见和禁止重排序