并发编程之单例模式,volatile和 synchronized
Posted
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了并发编程之单例模式,volatile和 synchronized相关的知识,希望对你有一定的参考价值。
单例模式
单例模式是众多设计模式中,可以说是最熟悉的了,但是由于单例模式的特殊性,我们需要在任何环境下,获取到的都是同一个实例,下面我们来看看。
首先,单例类有一下几个特点。
- 私有化的构造函数(防止外部自行创建实例)
- 私有化的静态全局变量(保存全局唯一实例)
- 公有的静态方法获取单例实例(返回单例实例)
众所周知,单例模式分为饿汉式和懒汉式,所谓饿汉式其实就是在类加载的时候,就把单例实例创建出来了,等需要使用的时候,直接获取就可以了,而懒汉式则在类加载的时候不创建,而是等到真正需要的时候才创建。
- 饿汉式
- 类加载即创建。
- 书写方便,简单。
- 使得项目初始化的时间更长。
- 懒汉式
- 使用时才创建。
- 不会占用项目初始化时间,在第一次获取的时候可能时间稍微久点。
下面我们分别来看一下饿汉式和懒汉式的代码:
饿汉式
饿汉式表示在类加载的时候,我们就把单例对象实例化出来,然后调用的时候就执行返回初始化时创建的对象即可。
public class UserSingleton
private UserSingleton()
private static UserSingleton instance = new UserSingleton();
public static UserSingleton getInstance()
return instance;
上面的几行代码,我们就实现了一个简单的饿汉式单例模式。由于饿汉式比较简单,就此止笔。
懒汉式
懒汉式则是在类加载的时候,不进行创建,等到第一次真正使用的时候创建,这样就可以防止一些类不是经常使用但是被一时间全部创建出来。占用启动资源。
我们看了前面的饿汉式创建方式,可能会瞬间写出下面的懒汉式:
线程不安全
public class StudentSingleton
private StudentSingleton()
private static StudentSingleton instance;
public static StudentSingleton getInstance()
if(instance == null)// ①
instance = new StudentSingleton();
return instance;
不就是在加载的时候再创建嘛。但如果对多线程有了解的同学就知道,这样是线程不安全的。可能线程1执行到①之后,CPU执行权被切换给线程2了,此时线程2一次执行完,返回新创建的instance,此时CPU执行权切回给线程1,由于之前已经判断了instance为null成立,此时继续执行实例化操作,导致线程1和线程2获取到的实例不是同一个。我们来测试一下:
public static void main(String[] args)
for (int i = 0; i < 10; i++)
new Thread(()->System.out.println(StudentSingleton.getInstance())).start();
我们通过新建10个线程来测试:如果测试结果不明显,可以在构造方法稍微睡眠1ms。然后多创建几个线程。
线程安全,效率不高
稍微有一点多线程知识的同学就可能知道,一般遇到线程安全问题,第一想到的就是synchronized关键字,那下面我们来实现synchronized 关键字来改造一下上面线程不安全的案例:
public class StudentSingleton
private StudentSingleton()
try
Thread.sleep(1);
catch (InterruptedException e)
e.printStackTrace();
private static StudentSingleton instance;
public static synchronized StudentSingleton getInstance()
if (instance == null)
instance = new StudentSingleton();
return instance;
测试程序:
这里使用到了线程池,先不展开说,后面会专门涉及到多线程的知识。其实这里也是通过单例模式来引入到多线程知识里面。
public static void main(String[] args)
ExecutorService executorService = Executors.newFixedThreadPool(10);
List<Future<?>> futures = new ArrayList<>();
long startTime = System.currentTimeMillis();
for (int i = 0; i < 10; i++)
futures.add(executorService.submit(() -> System.out.println(StudentSingleton.getInstance())));
for (Future<?> future : futures)
try
future.get();
catch (InterruptedException | ExecutionException e)
e.printStackTrace();
long endTime = System.currentTimeMillis();
System.out.println("spend time: "+ (endTime - startTime));
测试结果:
通过测试,我们可以发现,创建的实例地址一致。花费时间为 53 毫秒。我们再来改造。
double check
我们对synchronized 熟悉的同学知道,当synchronized修饰静态方法是,锁的是类对象。同时这里作用的是整个方法。而临界区并不是整个资源,所以我们将synchronized 修饰的作用于缩小到 真正创建实例的地方。
public class StudentSingleton
private StudentSingleton()
try
Thread.sleep(10);
catch (InterruptedException e)
e.printStackTrace();
private static StudentSingleton instance;
public static StudentSingleton getInstance()
if (instance == null)
synchronized (StudentSingleton.class)
if (instance == null)
instance = new StudentSingleton();
return instance;
测试程序:
public static void main(String[] args)
ExecutorService executorService = Executors.newCachedThreadPool();
List<Future<?>> futures = new ArrayList<>();
long startTime = System.currentTimeMillis();
for (int i = 0; i < 10000; i++)
futures.add(executorService.submit(() -> System.out.println(StudentSingleton.getInstance())));
for (Future<?> future : futures)
try
future.get();
catch (InterruptedException | ExecutionException e)
e.printStackTrace();
long endTime = System.currentTimeMillis();
System.out.println("spend time: "+ (endTime - startTime));
结果出乎意料,我把线程加到10000个,发现spend time 差不多。哈哈,虽然结果不是很明显,但是我们需要这样设计。就是尽量把synchronized作用到需要的地方,不要过大。至于synchronized后面会自己说。
内部类
这个方式就充分利用到了Java 的类加载方式了,是一种比较好的设计。我看大部分都建议只用这种。
public class StudentSingleton
private StudentSingleton()
private static class StudentSingletonHolder
private static final StudentSingleton INSTANCE = new StudentSingleton();
public static StudentSingleton getInstance()
return StudentSingletonHolder.INSTANCE;
由于在类加载的时候,内部类是不会进行加载的。所以满足了懒加载的要求。而当使用时,由于获取到的是StudentSingletonHolder 的静态成员变量,也只会初始化一次。所以又满足单例的要求。所以比较推荐使用这种来构建单例类。
至于在初始化时,内部类是否会加载,我们在测试一下,不详细说,后面的文章应该会介绍到。
public class StudentSingleton
static
System.out.println("外部静态代码块");
private StudentSingleton()
private static class StudentSingletonHolder
private static final StudentSingleton INSTANCE = new StudentSingleton();
static
System.out.println("内部静态代码块");
public static StudentSingleton getInstance()
return StudentSingletonHolder.INSTANCE;
public static void main(String[] args)
可以测试到,如果我们运行上述代码。只会输出外部静态代码块
。 而不会输出 内部静态代码块
。 只有等调用 getInstance
方法时,才会输出。这里也就可以验证到了,加载外部类时,不会加载内部类。
枚举
使用内部枚举类的方式也可以实现单例类。下面我们来看看
public class StudentSingleton
private enum Singleton
INSTANCE;
private final StudentSingleton instance;
Singleton()
instance = new StudentSingleton();
public StudentSingleton getInstance()
return instance;
public static StudentSingleton getInstance()
return Singleton.INSTANCE.getInstance();
这个方式我们就不自己说了,这里主要是利用了Java的枚举类的方式来实现单例类。但是可能比上面的方式更难理解一点,但是和内部类的方式又很类似。
这里使用了几种方式来实现懒汉式的单例类。但是我们需要来好好讲讲里面的double check。也就 是双重检查机制。在我们的印象中,双重检查机制实现的懒汉式就是这样的,但是其实是有一个问题的,就是在多线程环境下可能会出现异常。这涉及到了指令重排序(instruction reorder)的问题。
我们把上面拿下来分析一下:
public static StudentSingleton getInstance()
if (instance == null)
synchronized (StudentSingleton.class)
if (instance == null)
instance = new StudentSingleton();//*
return instance;
上述星号处其实包括三个步骤:
- 在堆内存中开辟空间 ①
- 调用构造方法,初始化对象信息 ②
- 将堆内存地址赋值给栈内存的instance ③
但是在 JVM 中,会根据一定的条件进行优化,导致部分不相互依赖的语句可能会进行顺序的调整,这样可以优化硬件的使用效率(这里不细说,关于一些寄存器了)。 经过指令重排之后,单线程下依旧可以保证最终结果一致性,如果连基本正确性都无法保证,那么优化也没有任何意义了。但是在多线程情况下,指令重排之后,就会出现一些问题了。
当发生指令重排后,线程1,在①处,顺序可能变为 1 -> 3 -> 2 , 而当执行完第二步赋值后,还没完成初始化,此时CPU执行切换到线程2。那么此时线程2进行instance判空的话,不为空,但是此时线程1的初始化操作可能没有完成。这样如果线程2的调用者在操作对象时,可能会造成某些对象没有,比如抛出空指针异常 NullPointerException. 那有什么解决办法呢?由于jdk5之后对volatile关键字加强作用后,可以使用关键字volatile关键字修饰instance。如下:
public class PersonSingleton
private PersonSingleton()
private static volatile PersonSingleton instance;
public static PersonSingleton getInstance()
if(instance == null)
synchronized(PersonSingleton.class)
if(instance == null)
instance = new PersonSingleton();
return instance;
我们知道synchronized关键字可以保证线程安全,即可以保证:①原子性 ②可见性 ③ 有序性。至于这三个特性的具体含义,这里不再详细描述了。但是,即使将创建对象的代码放在synchronized代码块中,也无法解决指令重排的问题,此时就需要使用我们的volatile关键字了。使用volatile关键字修饰instance之后,可以防止指令重排,即不会出现步骤2 和 步骤3 进行换序的问题 ( 1->2->3),也就不会出现线程2 读取到 instance不为null,但是可以使用内部属性出现异常的情况。
留疑:在double-check 下的单例模式,即使发生指令重排,是否异常只会发生在多核CPU下,单核CPU是不是不会出现问题呢?
经过讲述单例模式的几种方式,以及double-checked locking问题,引入了关键字synchronized和volatile, 那下面我们就真正的走进多线程。
并发的三大特性
三个并发基础 为了保证线程安全,Java并发有哪几个基本特性呢?
A:有三个特性:①原子性 ②可见性 ③有序性。
Q:好,能不能详细说一下这三个特性呢?
A:
- 原子性(Atomicity):这个其实和数据库的原子性是相似的,简单来说就是 要么全做完,要不一点都不做。在多线程中表现为 当线程A在执行一段代码时,不能被其他线程所干扰。同时在这段代码开始执行到执行完过程中,CPU的执行权不会能进行切换,即不能中断,要么全部执行完,要么就不执行,这样才能保证原子性。
- 可见性(Visibility):表现为线程A对共享资源的操作对其他线程是及时可见的,即其他线程可以立即知道共享资源发生了修改。显然在串行程序中,可见性问题是不存在的。因为你在任何一个操作步骤中修改了某一个变量,后续的步骤中都可以读取到这个变量的值,而且读取到的都是修改后的值。
- 有序性(Ordering):串行化的程序中,其实有序性的问题是不存在,即使JVM进行优化,也必须保证语句的最终一致性。所以在我们看来就相当于代码从上至下依次执行。 但是在多线程环境下,代码的顺序就难以预测了,可能由于指令重排的发生,会导致一些看似不可能发生的问题。所以:如果在本线程中观察,所有的操作都是有序的;如果在一个线程中观察另一个线程,所有操作都是无序的。前者是指“线程内表现为串行语义”。而后者是指“指令重排”现象和“工作内存和主内存同步延迟”现象。
对于有序性,有一些指令是不允许指令重排的,称为先行发生原则 (happens-before relationship)
- 程序顺序原则:一个线程内,按照代码顺序,书写在前面的操作先行发生于后面的操作。
- 锁定规则:一个unLock 操作先行发生于后面对同一个锁的lock操作。
- volatile变量规则:对一个变量的写操作先行发生于后面对这个变量的读操作。
- 传递规则:如果操作A先行发生于操作B,而操作B有先行发生于操作C,则可以得出操作A先行发生于操作C。
- 线程启动规则:Thread对象的start方法先行发生于此线程的每一个动作。
- 线程中断规则:对线程interrupt方法的调用先行发生于被中断线程的代码检测到中断事件的发生。
- 线程终结规则:线程中所有的操作都先行发生于线程的终止检测,我们可以通过Thread.join() 方法结束,Thread.isAlive() 的返回值手段检测到线程已经终止执行。
- 对象终结规则:一个对象的初始化完成先行发生于它的finalize()方法的开始。
synchronized
synchronized 关键字是JDK内置的。是一个内置可重入锁,据说在jdk5之前,这个内置锁的效率不怎么样,但是等到了jdk5了,jdk进行了大量的优化,现在的效率已经非常不错了。
那为什么我们会使用到synchronized呢?因为在多线程场景下,存在共享数据,多个线程会去操作共享数据,导致共享数据会出现线程安全的问题。所以我们需要使用synchronized关键字修饰,将共享资源的操作放置到同步代码中,使得每次只有一个线程进行操作。这样就可以保证线程安全了。
synchronized 可以保证并发安全,即能保证 ① 原子性 ② 有序性 ③可见性
synchronized 底层是使用字节码指令 monitorenter 和 monitorexit 实现的。
我们将下面这段代码使用javap命令看一下:
public class SynchronizedTest
public static synchronized void test()
public static void method()
synchronized (SynchronizedTest.class)
public static void main(String[] args)
可以看到,其中使用同步代码块的方法,多了monitorenter 和 monitorexit两条指令。
synchronized 关键字的三种使用方式:
- 修饰静态方法。锁的是当前类的class对象。
- 修饰普通方法。锁的是当前对象。(this)
- 修饰代码块。则锁的是指定的对象。
下面我们通过代码演示一下:
锁静态方法
public class MonitorObject
public static synchronized void method()
上述代码synchronized关键字锁的是 MonitorObject.class 对象。也就是上面的第一种情况。
锁普通方法
public class MonitorObject
public synchronized void method()
上述代码,调用method方法时,锁的是当前调用的对象。即下面的代码中,锁的是 obj 对象。
public static void main(String[] args)
MonitorObject obj = new MonitorObject();
obj.method();
锁代码块
public class MonitorObject
private final Object ob = new Object();
public void method()
synchronized(ob)
上述代码显而易见就是锁的ob对象。当多个线程来访问,都需要获取ob锁,如果被占用,则阻塞等待。
volatile
Java语言提供了一种稍弱的同步机制,即volatile变量,用来确保将变量的更新操作通知其他线程。
一旦一个共享变量(类的成员变量、类的静态成员变量)被 volatile修饰之后,那么久具备了两层语义:
1)保证了不同线程对这个变量进行操作时的可见性,即一个线程修改了某变量的值,这个新值对其他线程来说是立即可见的。
2)禁止进行指令重排。
现在的处理器都是多核CPU的,那我们用图来简单描述一下线程与主存的联系,可以发现中间有一个CPU cache。由于CPU与主存的数据的频繁交互,在一定程度上也会降低性能,所以现在的处理器中,一般CPU都会相应的告诉缓存。如果单核CPU到不会出现数据不一致的问题,因为都是操作相同的高速缓存,但是放在多核CPU就会出现问题。
如上图所示,假如线程1在CPU1中执行,而线程2在CPU2中执行。当两个线程都需要对主存中的共享变量进行操作时,CPU1和CPU2都会将Main Memory 中的共享变量拷贝一份到自己的cpu cache中,这样就会出现问题。
我们来看一下下面的代码,线程1先执行,线程2后执行。
//线程1
boolean stop = false;
while(!stop)
doSomething();
//线程2
stop = true;
这段代码我们不能把它放在一个方法里,需要分来,如下:
public class VolatileDemo2
public boolean stop = false;
public void method()
while (!stop)
System.out.println("doSomething...");
System.out.println("stop:" + stop);
public void updateStopState()
stop = true;
通过上述代码需要先ThreadA调用method,然后ThreadB调用updateStopState 方法。这段代码在测试过程中,很难遇到死循环的问题,但是这段代码理论上是有问题的。即:如果ThreadA和ThreadB分别在不同的CPU上执行的话,首先AB线程都会把共享数据stop拷贝一份放置到自己的高速缓存中,此时线程A判断自己的缓存的stop为false,循环执行,此时线程B修改stop为true,然后写回主存,但是此时ThreadA无法感知到变化,因为自己的高速缓存的stop一致是false,这样就会出现死循环的问题。此时就需要使用volatile 修饰stop变量了。
volatile修饰之后就变得不一样了:
第一:使用volatile关键字会强制将修改的值立即写入到主存。
第二:使用volatile关键字的话,当线程B进行修改时,会导致线程A的工作内存中缓存变量stop的缓存行无效。(反映到硬件层面的话,就是CPU的L1和L2缓存中对象的缓存行失效);
第三:由于线程A的工作内存中缓存变量stop的缓存行无效,所以线程A再次读取变量的值时会去主存中读取。
那么在线程B修改stop的值时(当然这里包括2个操作,修改线程2工作内存中的值,然后将修改后的值写回到主存),会使得线程A的工作内存中缓存变量stop的缓存行无效,然后线程A读取时,发现自己的缓存行无效,它会等待缓存行对应的主存地址被更新之后,然后去对应的主存读取最新的值。
那么线程A读取到的就是最新的正确的值。
volatile 能保证原子性吗?
我们先来看一个实际的例子:
public class VolatileDemo1
private volatile int num = 0;
public void method()
num++;
public static void main(String[] args)
ExecutorService executorService = Executors.newCachedThreadPool();
List<Future<?>> futureList = new ArrayList<>();
VolatileDemo1 volatileDemo1 = new VolatileDemo1();
for (int i = 0; i < 100; i++)
futureList.add(executorService.submit(volatileDemo1::method));
for (Future<?> future : futureList)
try
future.get();
catch (InterruptedException | ExecutionException e)
e.printStackTrace();
System.out.println(volatileDemo1.num);
executorService.shutdown();
通过运行上面的例子,通过输出结果我们就可以验证上面的例子。那么这是为什么呢?按我们正常的想法,应该是输出100,但是输出的结果基本都是小于100的。
如果使用idea的小朋友可能会发现,idea其实有提示:
基本就是:在 volatile 修饰的属性 num 上的操作不是原子操作。
那这里为什么不是原子操作呢?
num++;
其实num++ 计算不是一步到位的,需要分为三步:
- 第一步:read num from memory 读取num的值。
- 第二步:add 1 在原来的num基础上加1。
- 第三步:write num to memory 将num写回到内存中。
我们可以想象一下,此时num = 10, 当线程A执行到第二步,此时线程A的n变为11,但是还没来得及写回内存,此时CPU执行权切换到线程B,即使此时num是volatile变量,满足可见性,即直接会写回到内存中,但是由于CPU执行权的切换,导致A还没写回到内存中,B已经开始读,读取到的num还是10.执行到第三步,线程B写回到内存值为11. 此时CPU执行权切换回A,A将11写回内存,导致A和B写回的都是11。这也就是输出结果小于100的根本原因,所以volatile不能保证对变量的操作是原子性。
那如果需要实现上述功能呢,我们可以将代码进行改造:
方式1:使用synchronized关键字进行同步
private int num = 0;
public void method()
synchronized (VolatileDemo1.class)
num++;
仅以同步代码块示例。由于此时使用了同步代码块,可以保证并发的三个特性,所以这里num可以不用volatile修饰。
方式2:使用Lock锁
private int num = 0;
private final ReentrantLock lock = new ReentrantLock();
public void method()
try
lock.lock();
num++;
finally
lock.unlock();
使用Lock锁来保证临界区的线程安全。
方式3:使用AtomicInteger原子类
private final AtomicInteger num = new AtomicInteger(0);
public void method()
num.incrementAndGet();
在java1.5 的 java.util.concurrent.atomic包下提供了一些原子操作类,即对基本数据类型的自增(加1操作),自减(减一操作),以及加法操作(加一个数),减法操作(减一个数)进行了封装,保证这些操作是原子性操作。
volatile 能保证有序性吗?
这里博主的例子比较好,就直接引入过来了
volatile关键字可以禁止指令重排序,所以volatile在一定程度上是可以保证有序性的。
volatile关键字禁止指定重排序有两层意思:
1)当程序执行到volatile变量的读操作和写操作时,在其前面的操作的更改肯定全部已经进行,切结果已经对后面的操作可见;在其后面的操作肯定还没有进行;
2)在进行指令优化时,不能将在对volatile变量访问的语句放在其后面执行,也不能把volatile变量后面的语句放在其前面执行。
可能上面的说的比较绕,举个简单的例子:
// x,y 为非volatile变量
// flag为volatile变量
x = 2; //语句1
y = 0; //语句2
flag = true; //语句3
x = 4; //语句4
y = -1; //语句5
由于flag变量是volatile变量,那么在进行指令重排序的时候,不会将语句3放在语句1和语句2前面,也不会将语句3放在语句4和语句5后面,但是要注意语句1和语句2的顺序,语句4和语句5的顺序是不作任何保证的。并且volatile关键字能保证,执行到语句3时,语句1和语句2 必定是执行完毕了的,且语句1和语句2的执行结果对语句3和语句4,语句5是可以见的。
那么再看一个例子:
//线程1:
context=loadContext(); //语句1
inited = true; //语句2
//线程2:
while(!inited)
sleep();
doSomethingWithConfig(context);
如果inited 不是volatile修饰的变量的话,如果指定重排,可能会将语句2放在语句1前面,导致语句2先执行。此时线程2进来判断之后,可能此时context还没有加载完成,则会导致程序出错。
如果我们使用volatile修饰inited变量,就不会出现这种问题,可以保证语句1执行完之后,才会执行语句2,并且context可以保证可见性,线程2是可以立即感知到的。
volatile 的原理和实现机制
摘自《深入理解Java虚拟机》:
“观察加入volatile关键字和没有加入volatile关键字时所生产的汇编代码发现,加入volatile关键字时,会多出一个lock前缀指令”。
lock前缀指定实际上相当于一个内存屏障(也成为内存栅栏),内存屏障会提供三个功能:
1) 它确保指定重排序时不会把其后面的指令排到内存屏障之前的位置,也不会把前面的指令排到内存屏障的后面;即在执行到内存屏障这句指令时,在它前面的操作已经全部完成;
2) 它会强制将对缓存的修改操作立即写入主存;
3) 如果是写操作,它会导致其他CPU中对应的缓存行无效;
使用场景
1) 状态量标记
2) 屏障前后的一致性
以上是关于并发编程之单例模式,volatile和 synchronized的主要内容,如果未能解决你的问题,请参考以下文章