目录
前言
正文
1.立即加载/饿汉模式
2.延迟加载/懒汉模式
1.延迟加载/懒汉模式解析
2.延迟加载/懒汉模式的缺点
3.延迟加载/懒汉模式的解决方案
(1)声明 synchronized 关键字
(2)尝试同步代码块
(3)针对某些重要的代码进行单独的同步
(4)使用 DCL 双检查锁机制
(5)双检查锁 DCL 使用 volatile 的必要性
3.使用静态内置类实现单例模式
4.序列化和反序列化的单例模式实现
5.使用 static 静态代码块实现单例模式
6.使用 enum 枚举类型实现单例模式
7.完善使用 enum 枚举类实现单例模式
总结
前言
在单例模式与多线程技术相结合的过程中,我们能发现许多以前从未考虑过的问题。这些不良的程序设计如果应用在商业项目中将会带来非常大的麻烦。线程与某些技术相结合时,我们需要考虑的事情会更多。总的来说,在本节我们只需要考虑一件事,那就是:如何使单例模式与多线程结合时是安全的、正确的。
正文
在标准的23个设计模式中,单例模式在应用中是比较常见的。但多数常规的该模式教学并没有结合多线程技术进行介绍,这就造成在使用结合多线程的单例模式时会出现一些意外。这样的代码如果在生产环境中出现异常,有可能造成灾难性的后果。
1.立即加载/饿汉模式
什么是立即加载?立即加载就是使用类的时候已经将对象创建完毕。常见的实现办法就是 new 实例化。从中文的语境上来看,就是 “着急” “急迫” 的含义,所以也被称为 “饿汉模式”。
实现代码:
public class MyObject {
//立即加载方式 == 饿汉模式
private static MyObject myObject = new MyObject();
private MyObject() {
}
public static MyObject getInstance(){
return myObject;
}
}
public class MyThread extends Thread{
@Override
public void run() {
System.out.println(MyObject.getInstance().hashCode());
}
}
public class Run {
public static void main(String[] args) {
MyThread t1 = new MyThread();
MyThread t2 = new MyThread();
MyThread t3 = new MyThread();
t1.start();
t2.start();
t3.start();
}
}
程序运行结果如图:
控制台打印的 hashCode 是同一个值,说明对象是同一个,也就实现了立即加载型单例模式。
此代码版本是立即加载模式,缺点是不能有其他实例变量,因为 getInstance() 方法没有同步,所以有可能出现非线程安全问题,比如出现如下代码:
public class MyObject {
//立即加载方式 == 饿汉模式
private static MyObject myObject = new MyObject();
private MyObject() {
}
private static String username;
private static String password;
public static MyObject getInstance(){
username = "从不同的服务器取出值(有可能不一样),并赋值";
password = "从不同的服务器取出值(有可能不一样),并赋值";
//上面的赋值并没有被同步,所以极易出现非线程安全问题,导致变量值被覆盖。
return myObject;
}
}
2.延迟加载/懒汉模式
什么是延迟加载》延迟加载就是调用 get() 方法时,实例才被工厂创建。常见的实现办法就是在 get() 方法中进行 new 实例化。 延迟加载从中文的预警来看,是 “缓慢” “不急迫” 的含义,所以也被称为 “懒汉模式”。
1.延迟加载/懒汉模式解析
实现代码:
public class MyObject {
private static MyObject myObject;
public MyObject() {
}
public static MyObject getInstance(){
//延迟加载
if (myObject != null){
}else {
myObject = new MyObject();
}
return myObject;
}
}
public class MyThread extends Thread{
@Override
public void run() {
System.out.println(MyObject.getInstance().hashCode());
}
}
public class Run {
public static void main(String[] args) {
MyThread t1 = new MyThread();
t1.start();
MyThread t2 = new MyThread();
t2.start();
}
}
程序运行结果如图:
此代码虽然取得一个对象的实例,但在多线程环境中会出现取出多个实例的情况,与单例模式的初衷是违背的。
2.延迟加载/懒汉模式的缺点
前面两个实验虽然使用了 "立即加载" 和 "延迟加载" 实现了单例模式,但在多线程环境中,"延迟加载" 示例中的代码完全是错误的,跟本不能保持单例的状态。下面来看如何在多线程环境中结合错误的单例模式创建出多个实例的。
public class MyObject {
private static MyObject myObject;
private MyObject(){
}
public static MyObject getInstance(){
try {
if (myObject != null){
}else {
//模拟创建对象之前做一些准备工作
Thread.sleep(3000);
myObject = new MyObject();
}
} catch (InterruptedException e) {
e.printStackTrace();
}
return myObject;
}
}
public class MyThread extends Thread{
@Override
public void run() {
System.out.println(MyObject.getInstance().hashCode());
}
}
public class Run {
public static void main(String[] args) {
MyThread t1 = new MyThread();
MyThread t2 = new MyThread();
MyThread t3 = new MyThread();
t1.start();
t2.start();
t3.start();
}
}
程序运行结果如图所示:
控制台打印出 3 种 hashCode,说明创建出 3 个对象,并不是单例的,这就是 "错误的单例模式",如何解决呢?下面看一下解决方案。
3.延迟加载/懒汉模式的解决方案
(1)声明 synchronized 关键字
既然多个线程可以同时进入 getInstance() 方法,我们只需要对 getInstance() 方法声明 synchronized 关键字即可。
public class MyObject {
private static MyObject myObject;
private MyObject(){
}
//设置同步方法效率太低
//整个方法被上锁
synchronized public static MyObject getInstance(){
try {
if (myObject != null){
}else {
//模拟创建对象之前做一些准备工作
Thread.sleep(3000);
myObject = new MyObject();
}
} catch (InterruptedException e) {
e.printStackTrace();
}
return myObject;
}
}
public class MyThread extends Thread{
@Override
public void run() {
System.out.println(MyObject.getInstance().hashCode());
}
}
public class Run {
public static void main(String[] args) {
MyThread t1 = new MyThread();
MyThread t2 = new MyThread();
MyThread t3 = new MyThread();
t1.start();
t2.start();
t3.start();
}
}
运行结果如图:
此方法在加入同步 synchronized 关键字后得到相同实例的对象,但运行效率非常低。下一个线程想要取得对象,必须等上一个线程释放完锁之后,才可以执行。那换成同步代码块可以解决吗?
(2)尝试同步代码块
创建测试用例
public class MyObject {
private static MyObject myObject;
private MyObject(){
}
//设置同步方法效率太低
//整个方法被上锁
public static MyObject getInstance(){
try {
//与同步方法等同
//效率一样很低,并不能减少锁的粒度
// (已经是最小的范围了,必须要在if判断前加锁,不然进入else还是会创建多个对象)
// 全部代码同步运行
synchronized (MyObject.class) {
if (myObject != null){
}else {
//模拟创建对象之前做一些准备工作
Thread.sleep(3000);
myObject = new MyObject();
}
}
} catch (InterruptedException e) {
e.printStackTrace();
}
return myObject;
}
}
public class MyThread extends Thread{
@Override
public void run() {
System.out.println(MyObject.getInstance().hashCode());
}
}
public class Run {
public static void main(String[] args) {
MyThread t1 = new MyThread();
MyThread t2 = new MyThread();
MyThread t3 = new MyThread();
t1.start();
t2.start();
t3.start();
//此版本代码虽然是正确的
//但全部代码都是同步的,这样做也有损效率
}
}
运行结果如图 :
此方法在加入同步 synchronized 语句块后得到相同实例的对象,但运行效率也非常低,和synchronized 同步方法一样是同步运行的。下面继续更改代码,尝试解决这个问题。
(3)针对某些重要的代码进行单独的同步
同步代码块可以仅针对某些重要的代码进行单独的同步,这可以大幅度提升效率 。
创建代码如下:
public class MyObject {
private static MyObject myObject;
private MyObject(){
}
public static MyObject getInstance(){
try {
if (myObject != null){
}else {
//模拟创建对象之前做一些准备工作
Thread.sleep(3000);
//使用部分代码被上锁
//但还是有非线程安全问题
//多次创建 MyObject 类的对象,结果并不是单例
synchronized (MyObject.class) {
myObject = new MyObject();
}
}
} catch (InterruptedException e) {
e.printStackTrace();
}
return myObject;
}
}
public class MyThread extends Thread{
@Override
public void run() {
System.out.println(MyObject.getInstance().hashCode());
}
}
public class Run {
public static void main(String[] args) {
MyThread t1 = new MyThread();
MyThread t2 = new MyThread();
MyThread t3 = new MyThread();
t1.start();
t2.start();
t3.start();
}
}
程序结果如图:
此方法使同步 synchronized 语句块只对实例化对象的关键代码进行同步。从语句的结构上讲,运行的效率的确得到了提升 ,但遇到多线程情况还是无法得到同一个实例对象。到底如何解决懒汉模式下的多线程情况呢?
(4)使用 DCL 双检查锁机制
下面使用 DCL 双重检查锁机制来实现多线程环境中的延迟加载单例模式。
- 第一次检查(无锁操作): 检查实例是否已经被创建,如果已经被创建,则直接返回实例,可以减少不必要的锁竞争(即每次都进入synchronized中);
- 第二次检查(锁内操作): 如果第一次检查发现实例尚未创建,代码会进入一个同步块,但在创建实例之前,会再次检查实例是否被创建。这是必要的,因为在当前线程进入同步块之前,可能有另一个线程已经创建了实例。
- 性能优化:通过减少同步的使用,DCL减少了不必要的性能开销,因为实例一旦被创建后,就不再需要同步。
- 线程安全:通过同步块确保在实例未初始化时,只有一个线程能创建单例实例,保持了单例的线程安全约定。
- 资源利用最优化:由于同步只在实际需要时才会发生,因此在资源利用上比始终同步要有效得多。
要注意的是,在Java中使用DCL的时候还需要考虑Java内存模型的因素。在多线程环境下,为了确保DCL正确地工作,单例对象的引用需要被声明为
volatile
,这样可以防止指令重排序可能导致的DCL失效问题。
public class MyObject {
private volatile static MyObject myObject;
private MyObject(){
}
//设置同步方法效率太低
//整个方法被上锁
public static MyObject getInstance(){
try {
if (myObject != null){
}else {
//模拟创建对象之前做一些准备工作
Thread.sleep(3000);
synchronized (MyObject.class) {
if (myObject==null){
myObject = new MyObject();
}
}
}
} catch (InterruptedException e) {
e.printStackTrace();
}
return myObject;
}
//此版本的代码称为:
//双重检查 Double-Check
}
使用 volatile 修饰变量 myObject ,使该变量在多个线程间可见,另外禁止 myObect = new MyObject() 代码重排序。myObject = new MyObject(); 代码包含 3 个步骤。
- memory = allocate(); //分配对象的内存空间
- ctorInstance(memory); //初始化对象
- myObject = memory; //设置 instance 指向刚分配的内存地址
JIT 编译器有可能将这三个步骤重排序成。
- memory = allocate(); //分配对象的内存空间
- myObject = memory; //设置 instance 指向刚分配的内存地址
- ctorInstance(memory); //初始化对象
这时,构造方法虽然还没有执行,但 myObject 对象已具有内存地址,即值不是 null。当访问 myObject 对象中的值时,是当前声明数据类型的默认值,此知识点在后面的章节中有讲解。
创建线程类的代码如下:
public class MyThread extends Thread{
@Override
public void run() {
System.out.println(MyObject.getInstance().hashCode());
}
}
创建运行类的代码如下:
public class Run {
public static void main(String[] args) {
MyThread t1 = new MyThread();
MyThread t2 = new MyThread();
MyThread t3 = new MyThread();
t1.start();
t2.start();
t3.start();
}
}
运行结果如图所示:
可见 DCL 双检查锁成功解决了懒汉模式下的多线程问题。DCL 也是大多数线程结合单例模式使用的解决方案 。
(5)双检查锁 DCL 使用 volatile 的必要性
前面介绍了 myObject = new MyObject() 代码中的 3 个步骤会发生重排序,导致取得实例变量的值不是构造方法初始化后的值。下面开始验证。
创建测试用例
package org.example.singleton;
import java.util.Random;
import java.util.concurrent.CountDownLatch;
public class dcl_and_volatile {
static class OneInstanceService {
public int i_am_has_state = 0;
private volatile static OneInstanceService test;
public OneInstanceService() {
this.i_am_has_state = new Random().nextInt(200) + 1;
}
public static OneInstanceService getTest1() {
if (test == null) {
synchronized (OneInstanceService.class) {
if (test == null) {
test = new OneInstanceService();
}
}
}
return test;
}
public static void reset() {
test = null;
}
}
public static void main(String[] args) throws InterruptedException {
for (; ; ) {
//允许一个或多个线程等待直到在其他线程中执行的一组操作完成的同步辅助。
CountDownLatch latch = new CountDownLatch(1);
CountDownLatch end = new CountDownLatch(100);
for (int i = 0; i < 100; i++) {
Thread t1 = new Thread() {
@Override
public void run() {
try {
//导致当前线程等到锁存器计数到零,除非线程是 interrupted 。
//创建 100 个线程在这里等待
latch.await();
OneInstanceService one = OneInstanceService.getTest1();
if (one.i_am_has_state == 0) {
System.out.println("one.i_am_has_state == 0 进程结束");
System.exit(0);
}
//减少锁存器的计数,如果计数达到零,释放所有等待的线程。
end.countDown();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
};
t1.start();
}
//循环完毕,创建结束,减掉计数1,线程被唤醒开始执行
latch.countDown();
//等待计数为0,也就是100个线程执行完成
end.await();
//重置
OneInstanceService.reset();
}
}
}
程序在运行时添加 VM 参数 -server 会更容易获得预期的结果,运行后控制台结果如图:
说明 myObject = new myObject() 确实发生了重排序。
更改代码:
static class OneInstanceService{
public int i_am_has_state = 0;
private volatile static OneInstanceService test;
public OneInstanceService() {
this.i_am_has_state = new Random().nextInt(200)+1;
}
public static OneInstanceService getTest1(){
if (test == null){
synchronized (OneInstanceService.class){
if (test == null){
test = new OneInstanceService();
}
}
}
return test;
}
public static void reset(){
test = null;
}
}
程序运行后不再打印任何信息,说明禁止重排序后,实例变量 i_am_has_state 永远不是 0 了。也就是说,步骤 A 开辟空间 B 来执行构造方法 C,在赋值代码中插入屏障 ,防止 B 跑到 C 的后面,这样执行顺序永远是 ABC ,而且使用 volatile 还保证了变量的值在多个线程间可见。
3.使用静态内置类实现单例模式
DCL 可以解决多线程单例模式的非线程安全问题。我们还可以使用其他办法达到同样的效果。
创建新的测试用例:
public class MyObject {
private static class MyobjectHandler {
private static MyObject myObject = new MyObject();
}
private MyObject() {
}
public static MyObject getInstance() {
return MyobjectHandler.myObject;
}
}
public class MyThread extends Thread{
@Override
public void run() {
System.out.println(MyObject.getInstance().hashCode());
}
}
public class Run {
public static void main(String[] args) {
MyThread t1 = new MyThread();
MyThread t2 = new MyThread();
MyThread t3 = new MyThread();
t1.start();
t2.start();
t3.start();
}
}
程序运行后的效果如图:
4.序列化和反序列化的单例模式实现
如果将单例对象进行序列化,使用默认的反序列行为取出对象是多例的。
创建测试用例:
//实体类代码
public class Userinfo {
}
//创建类 MyObject.java
import java.io.ObjectStreamException;
import java.io.Serializable;
public class MyObject implements Serializable {
private static final long serialVersionUID = 888L;
public static Userinfo userinfo = new Userinfo();
private static MyObject myObject = new MyObject();
private MyObject() {
}
public static MyObject getInstance() {
return myObject;
}
/*protected Object readResolve() throws ObjectStreamException {
System.out.println("调用了 readResolve方法!");
return MyObject.myObject;
}*/
}
方法 protected Object readResolve() 的作用是反序列化时不创建新的 MyObject 对象,而是复用原有的 MyObject 对象。
创建业务类代码:
import java.io.*;
public class SaveAndRead {
public static void main(String[] args) {
try {
MyObject myObject = MyObject.getInstance();
System.out.println("序列化 -myObject="+myObject.hashCode()+" userinfo="+myObject.userinfo.hashCode());
FileOutputStream fosRef = new FileOutputStream(new File("myObjectFile.txt"));
ObjectOutput oosRef = new ObjectOutputStream(fosRef);
oosRef.writeObject(myObject);
oosRef.close();
fosRef.close();
} catch (FileNotFoundException e) {
e.printStackTrace();
} catch (IOException e) {
e.printStackTrace();
}
try {
FileInputStream fisRef = new FileInputStream(new File("myObjectFile.txt"));
ObjectInput iosRef = new ObjectInputStream(fisRef);
MyObject myObject = (MyObject) iosRef.readObject();
iosRef.close();
fisRef.close();
System.out.println(" 序列化 -myObject="+myObject.hashCode()+" userinfo="+myObject.userinfo.hashCode());
} catch (FileNotFoundException e) {
e.printStackTrace();
} catch (IOException e) {
e.printStackTrace();
} catch (ClassNotFoundException e) {
e.printStackTrace();
}
}
}
程序运行结果如图:
从打印结果可以分析出,在反序列化时创建新的 MyObject 对象,但 Userinfo 对象得到复用,因为 hashcode 是同一个 1163157884 。为了实现 MyObject 在内存中一直呈单例效果,我们可以在反序列化中使用 readResolve() 方法,对原有的 MyObject 对象进行复用:
protected Object readResolve() throws ObjectStreamException {
System.out.println("调用了 readResolve方法!");
return MyObject.myObject;
}
程序运行结果如图:
方法 protected Object readResolve() 的作用是在反序列化时不创建新的 MyObject 对象,而是复用 JVM 内存中原有的 MyObject 单例对象,即 Userinfo 对象被复用,这就实现了对 MyObject 序列化与反序列化时保持单例性。
注意:如果将序列化和反序列化操作分别放入两个 class,反序列化时会产生新的 MyObject 对象。放在 2 个 class 类中分别执行其实相当于创建了 2 个 JVM 虚拟机,每个虚拟机里有 1 个 MyObject 对象。我们想要实现的是在 1 个 JVM 虚拟机中进行序列化与反序列化时保持 MyObject 单例性,而不是创建 2 个 JVM 虚拟机。
补充: 在Java中,对象的序列化是将对象的状态(state)序列化为字节流,而不是重新创建对象。在序列化过程中,对象的引用会被保存下来,而不是对象本身。反序列化时,根据保存的引用创建新的对象,并将序列化的状态恢复到新对象中。 在这段代码中,userinfo对象作为myObject对象的成员变量,被序列化时也一起序列化了。反序列化时,根据之前的引用创建新的myObject对象,并将序列化的userinfo对象的状态恢复到新对象中。因此,userinfo对象并没有被重新创建,而是在序列化和反序列化过程中被复原了状态。
5.使用 static 静态代码块实现单例模式
静态代码块中的代码在使用类的时候就已经执行,所以我们可以应用静态代码块的这个特性实现单例模式。
public class MyObject {
private static MyObject instance = null;
private MyObject(){
}
static {
instance = new MyObject();
}
public static MyObject getInstance(){
return instance;
}
}
public class MyThread extends Thread{
@Override
public void run() {
for (int i = 0; i < 5; i++) {
System.out.println(MyObject.getInstance().hashCode());
}
}
}
public class Run {
public static void main(String[] args) {
MyThread t1 = new MyThread();
MyThread t2 = new MyThread();
MyThread t3 = new MyThread();
t1.start();
t2.start();
t3.start();
}
}
运行结果如图:
6.使用 enum 枚举类型实现单例模式
枚举 enum 和静态代码块的特性相似。在使用枚举类时,构造方法会被自动调用。我们也可以应用这个特性实现单例模式。
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.SQLException;
public enum MyObject {
connectionFactory;
private Connection connection;
private MyObject() {
try {
System.out.println("调用了 MyObject 的构造器");
String url = "jdbc:mysql://localhost:3306/spring_boot";
String username = "root";
String password = "123456";
String driverName = "com.mysql.cj.jdbc.Driver";
Class.forName(driverName);
connection = DriverManager.getConnection(url,username,password);
} catch (ClassNotFoundException e) {
throw new RuntimeException(e);
} catch (SQLException e) {
e.printStackTrace();
}
}
public Connection getConnection(){
return connection;
}
}
public class MyThread extends Thread{
@Override
public void run() {
for (int i = 0; i <5; i++) {
System.out.println(MyObject.connectionFactory.getConnection().hashCode());
}
}
}
public class Run {
public static void main(String[] args) {
MyThread t1 = new MyThread();
MyThread t2 = new MyThread();
MyThread t3 = new MyThread();
t1.start();
t2.start();
t3.start();
}
}
程序运行结果如图:
7.完善使用 enum 枚举类实现单例模式
修改 MyObject.java
public class MyObject {
public enum MyEnumSingleton{
connectionFactory;
private Connection connection;
private MyEnumSingleton() {
try {
System.out.println("创建了 MyObject 对象");
String url = "jdbc:mysql://localhost:3306/spring_boot";
String username = "root";
String password = "123456";
String driverName = "com.mysql.cj.jdbc.Driver";
Class.forName(driverName);
connection = DriverManager.getConnection(url,username,password);
} catch (ClassNotFoundException e) {
throw new RuntimeException(e);
} catch (SQLException e) {
e.printStackTrace();
}
}
private Connection getConnection(){
return connection;
}
}
public static Connection getConnection(){
return MyEnumSingleton.connectionFactory.getConnection();
}
}
更改 MyThread.java 类的代码:
public class MyThread extends Thread{
@Override
public void run() {
for (int i = 0; i <5; i++) {
System.out.println(MyObject.getConnection().hashCode());
}
}
}
运行结果如图:
总结
加油!!!