设计模式 | 单例模式及典型应用

网友投稿 707 2022-05-30

设计模式 | 单例模式及典型应用

微信原文:设计模式 | 单例模式及典型应用

博客原文:设计模式 | 单例模式及典型应用

单例是最常见的设计模式之一,实现的方式非常多,同时需要注意的问题也非常多。

本文主要内容:

介绍单例模式

介绍单例模式的N中写法

单例模式的安全性

序列化攻击

反射攻击

单例模式总结

介绍单例模式的典型应用

单例模式

单例模式(Singleton Pattern):确保某一个类只有一个实例,而且自行实例化并向整个系统提供这个实例,这个类称为单例类,它提供全局访问的方法。单例模式是一种对象创建型模式。

单例模式有三个要点:

构造方法私有化;

实例化的变量引用私有化;

获取实例的方法共有

Singleton(单例):在单例类的内部实现只生成一个实例,同时它提供一个静态的 getInstance() 工厂方法,让客户可以访问它的唯一实例;为了防止在外部对其实例化,将其构造函数设计为私有;在单例类内部定义了一个 Singleton 类型的静态对象,作为外部共享的唯一实例。

单例模式的七种写法

// 线程安全

public class Singleton {

private final static Singleton INSTANCE = new Singleton();

private Singleton(){}

public static Singleton getInstance(){

return INSTANCE;

}

}

优点:简单,使用时没有延迟;在类装载时就完成实例化,天生的线程安全

缺点:没有懒加载,启动较慢;如果从始至终都没使用过这个实例,则会造成内存的浪费。

// 线程安全

public class Singleton {

private static Singleton instance;

static {

instance = new Singleton();

}

private Singleton() {}

public static Singleton getInstance() {

return instance;

}

}

将类实例化的过程放在了静态代码块中,在类装载的时执行静态代码块中的代码,初始化类的实例。优缺点同上。

// 线程不安全

public class Singleton {

private static Singleton singleton;

private Singleton() {}

public static Singleton getInstance() {

if (singleton == null) {

singleton = new Singleton();

}

return singleton;

}

}

优点:懒加载,启动速度快、如果从始至终都没使用过这个实例,则不会初始化该实力,可节约资源

缺点:多线程环境下线程不安全。if (singleton == null) 存在竞态条件,可能会有多个线程同时进入 if 语句,导致产生多个实例

// 线程安全,效率低

public class Singleton {

private static Singleton singleton;

private Singleton() {}

public static synchronized Singleton getInstance() {

if (singleton == null) {

singleton = new Singleton();

}

return singleton;

}

}

优点:解决了上一种实现方式的线程不安全问题

缺点:synchronized 对整个 getInstance() 方法都进行了同步,每次只有一个线程能够进入该方法,并发性能极差

// 线程安全

public class Singleton {

// 注意:这里有 volatile 关键字修饰

private static volatile Singleton singleton;

private Singleton() {}

public static Singleton getInstance() {

if (singleton == null) {

synchronized (Singleton.class) {

if (singleton == null) {

singleton = new Singleton();

}

}

}

return singleton;

}

}

优点:线程安全;延迟加载;效率较高。

由于 JVM 具有指令重排的特性,在多线程环境下可能出现  singleton 已经赋值但还没初始化的情况,导致一个线程获得还没有初始化的实例。volatile 关键字的作用:

保证了不同线程对这个变量进行操作时的可见性

禁止进行指令重排序

// 线程安全

public class Singleton {

private Singleton() {}

private static class SingletonInstance {

private static final Singleton INSTANCE = new Singleton();

}

public static Singleton getInstance() {

return SingletonInstance.INSTANCE;

}

}

优点:避免了线程不安全,延迟加载,效率高。

静态内部类的方式利用了类装载机制来保证线程安全,只有在第一次调用getInstance方法时,才会装载SingletonInstance内部类,完成Singleton的实例化,所以也有懒加载的效果。

加入参数 -verbose:class 可以查看类加载顺序

$ javac Singleton.java

$ java -verbose:class Singleton

// 线程安全

public enum Singleton {

INSTANCE;

public void whateverMethod() {

}

}

优点:通过JDK1.5中添加的枚举来实现单例模式,写法简单,且不仅能避免多线程同步问题,而且还能防止反序列化重新创建新的对象。

单例模式的安全性

单例模式的目标是,任何时候该类都只有唯一的一个对象。但是上面我们写的大部分单例模式都存在漏洞,被攻击时会产生多个对象,破坏了单例模式。

通过Java的序列化机制来攻击单例模式

public class HungrySingleton {

private static final HungrySingleton instance = new HungrySingleton();

private HungrySingleton() {

}

public static HungrySingleton getInstance() {

return instance;

}

public static void main(String[] args) throws IOException, ClassNotFoundException {

HungrySingleton singleton = HungrySingleton.getInstance();

ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("singleton_file"));

oos.writeObject(singleton); // 序列化

ObjectInputStream ois = new ObjectInputStream(new FileInputStream("singleton_file"));

HungrySingleton newSingleton = (HungrySingleton) ois.readObject(); // 反序列化

System.out.println(singleton);

System.out.println(newSingleton);

System.out.println(singleton == newSingleton);

}

}

结果

com.singleton.HungrySingleton@ed17bee

com.singleton.HungrySingleton@46f5f779

false

Java 序列化是如何攻击单例模式的呢?我们需要先复习一下Java的序列化机制

java.io.ObjectOutputStream 是Java实现序列化的关键类,它可以将一个对象转换成二进制流,然后可以通过 ObjectInputStream 将二进制流还原成对象。具体的序列化过程不是本文的重点,在此仅列出几个要点。

Java 序列化机制的要点:

需要序列化的类必须实现java.io.Serializable接口,否则会抛出NotSerializableException异常

若没有显示地声明一个serialVersionUID变量,Java序列化机制会根据编译时的class自动生成一个serialVersionUID作为序列化版本比较(验证一致性),如果检测到反序列化后的类的serialVersionUID和对象二进制流的serialVersionUID不同,则会抛出异常

Java的序列化会将一个类包含的引用中所有的成员变量保存下来(深度复制),所以里面的引用类型必须也要实现java.io.Serializable接口

当某个字段被声明为transient后,默认序列化机制就会忽略该字段,反序列化后自动获得0或者null值

静态成员不参与序列化

每个类可以实现readObject、writeObject方法实现自己的序列化策略,即使是transient修饰的成员变量也可以手动调用ObjectOutputStream的writeInt等方法将这个成员变量序列化。

任何一个readObject方法,不管是显式的还是默认的,它都会返回一个新建的实例,这个新建的实例不同于该类初始化时创建的实例

每个类可以实现private Object readResolve()方法,在调用readObject方法之后,如果存在readResolve方法则自动调用该方法,readResolve将对readObject的结果进行处理,而最终readResolve的处理结果将作为readObject的结果返回。readResolve的目的是保护性恢复对象,其最重要的应用就是保护性恢复单例、枚举类型的对象

Serializable接口是一个标记接口,可自动实现序列化,而Externalizable继承自Serializable,它强制必须手动实现序列化和反序列化算法,相对来说更加高效

根据上面对Java序列化机制的复习,我们可以自定义一个 readResolve,在其中返回类的单例对象,替换掉 readObject 方法反序列化生成的对象,让我们自己写的单例模式实现保护性恢复对象

public class HungrySingleton implements Serializable {

private static final HungrySingleton instance = new HungrySingleton();

private HungrySingleton() {

}

public static HungrySingleton getInstance() {

return instance;

}

private Object readResolve() {

return instance;

}

public static void main(String[] args) throws IOException, ClassNotFoundException {

HungrySingleton singleton = HungrySingleton.getInstance();

ObjectInputStream ois = new ObjectInputStream(new FileInputStream("singleton_file"));

HungrySingleton newSingleton = (HungrySingleton) ois.readObject();

System.out.println(singleton);

System.out.println(newSingleton);

System.out.println(singleton == newSingleton);

}

}

再次运行

com.singleton.HungrySingleton@24273305

com.singleton.HungrySingleton@24273305

true

注意:自己实现的单例模式都需要避免被序列化破坏

在单例模式中,构造器都是私有的,而反射可以通过构造器对象调用 setAccessible(true) 来获得权限,这样就可以创建多个对象,来破坏单例模式了

public class HungrySingleton {

private static final HungrySingleton instance = new HungrySingleton();

private HungrySingleton() {

}

public static HungrySingleton getInstance() {

return instance;

}

public static void main(String[] args) throws NoSuchMethodException, IllegalAccessException, InvocationTargetException, InstantiationException {

HungrySingleton instance = HungrySingleton.getInstance();

Constructor constructor = HungrySingleton.class.getDeclaredConstructor();

constructor.setAccessible(true);    // 获得权限

HungrySingleton newInstance = (HungrySingleton) constructor.newInstance();

System.out.println(instance);

System.out.println(newInstance);

System.out.println(instance == newInstance);

}

}

输出结果

com.singleton.HungrySingleton@3b192d32

com.singleton.HungrySingleton@16f65612

false

反射是通过它的Class对象来调用构造器创建新的对象,我们只需要在构造器中检测并抛出异常就可以达到目的了

private HungrySingleton() {

// instance 不为空,说明单例对象已经存在

if (instance != null) {

throw new RuntimeException("单例模式禁止反射调用!");

}

}

运行结果

Exception in thread "main" java.lang.reflect.InvocationTargetException

at sun.reflect.NativeConstructorAccessorImpl.newInstance0(Native Method)

at sun.reflect.NativeConstructorAccessorImpl.newInstance(NativeConstructorAccessorImpl.java:62)

at sun.reflect.DelegatingConstructorAccessorImpl.newInstance(DelegatingConstructorAccessorImpl.java:45)

at java.lang.reflect.Constructor.newInstance(Constructor.java:423)

at com.singleton.HungrySingleton.main(HungrySingleton.java:32)

Caused by: java.lang.RuntimeException: 单例模式禁止反射调用!

at com.singleton.HungrySingleton.(HungrySingleton.java:20)

... 5 more

注意,上述方法针对饿汉式单例模式是有效的,但对懒汉式的单例模式是无效的,懒汉式的单例模式是无法避免反射攻击的!

为什么对饿汉有效,对懒汉无效?因为饿汉的初始化是在类加载的时候,反射一定是在饿汉初始化之后才能使用;而懒汉是在第一次调用 getInstance() 方法的时候才初始化,我们无法控制反射和懒汉初始化的先后顺序,如果反射在前,不管反射创建了多少对象,instance都将一直为null,直到调用 getInstance()。

事实上,实现单例模式的唯一推荐方法,是使用枚举类来实现。

写下我们的枚举单例模式

package com.singleton;

import java.io.*;

import java.lang.reflect.Constructor;

import java.lang.reflect.InvocationTargetException;

public enum SerEnumSingleton implements Serializable {

INSTANCE;   // 单例对象

private String content;

public String getContent() {

return content;

}

public void setContent(String content) {

this.content = content;

}

private SerEnumSingleton() {

}

public static void main(String[] args) throws IOException, ClassNotFoundException, NoSuchMethodException, IllegalAccessException, InvocationTargetException, InstantiationException {

SerEnumSingleton singleton1 = SerEnumSingleton.INSTANCE;

singleton1.setContent("枚举单例序列化");

System.out.println("枚举序列化前读取其中的内容:" + singleton1.getContent());

ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("SerEnumSingleton.obj"));

oos.writeObject(singleton1);

oos.flush();

oos.close();

FileInputStream fis = new FileInputStream("SerEnumSingleton.obj");

ObjectInputStream ois = new ObjectInputStream(fis);

SerEnumSingleton singleton2 = (SerEnumSingleton) ois.readObject();

ois.close();

System.out.println(singleton1 + "\n" + singleton2);

System.out.println("枚举序列化后读取其中的内容:" + singleton2.getContent());

System.out.println("枚举序列化前后两个是否同一个:" + (singleton1 == singleton2));

Constructor constructor = SerEnumSingleton.class.getDeclaredConstructor();

constructor.setAccessible(true);

SerEnumSingleton singleton3 = constructor.newInstance(); // 通过反射创建对象

System.out.println("反射后读取其中的内容:" + singleton3.getContent());

System.out.println("反射前后两个是否同一个:" + (singleton1 == singleton3));

}

}

运行结果,序列化前后的对象是同一个对象,而反射的时候抛出了异常

枚举序列化前读取其中的内容:枚举单例序列化

INSTANCE

INSTANCE

枚举序列化后读取其中的内容:枚举单例序列化

枚举序列化前后两个是否同一个:true

Exception in thread "main" java.lang.NoSuchMethodException: com.singleton.SerEnumSingleton.()

at java.lang.Class.getConstructor0(Class.java:3082)

at java.lang.Class.getDeclaredConstructor(Class.java:2178)

at com.singleton.SerEnumSingleton.main(SerEnumSingleton.java:39)

编译后,再通过 JAD 进行反编译得到下面的代码

// Decompiled by Jad v1.5.8g. Copyright 2001 Pavel Kouznetsov.

// Jad home page: http://www.kpdus.com/jad.html

// Decompiler options: packimports(3)

// Source File Name:   SerEnumSingleton.java

package com.singleton;

import java.io.*;

import java.lang.reflect.Constructor;

import java.lang.reflect.InvocationTargetException;

public final class SerEnumSingleton extends Enum

implements Serializable

{

public static SerEnumSingleton[] values()

{

return (SerEnumSingleton[])$VALUES.clone();

}

public static SerEnumSingleton valueOf(String name)

{

return (SerEnumSingleton)Enum.valueOf(com/singleton/SerEnumSingleton, name);

}

public String getContent()

{

return content;

}

public void setContent(String content)

{

this.content = content;

}

private SerEnumSingleton(String s, int i)

{

super(s, i);

}

public static void main(String args[])

throws IOException, ClassNotFoundException, NoSuchMethodException, IllegalAccessException, InvocationTargetException, InstantiationException

{

SerEnumSingleton singleton1 = INSTANCE;

singleton1.setContent("\u679A\u4E3E\u5355\u4F8B\u5E8F\u5217\u5316");

System.out.println((new StringBuilder()).append("\u679A\u4E3E\u5E8F\u5217\u5316\u524D\u8BFB\u53D6\u5176\u4E2D\u7684\u5185\u5BB9\uFF1A").append(singleton1.getContent()).toString());

ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("SerEnumSingleton.obj"));

oos.writeObject(singleton1);

oos.flush();

oos.close();

FileInputStream fis = new FileInputStream("SerEnumSingleton.obj");

ObjectInputStream ois = new ObjectInputStream(fis);

SerEnumSingleton singleton2 = (SerEnumSingleton)ois.readObject();

ois.close();

System.out.println((new StringBuilder()).append(singleton1).append("\n").append(singleton2).toString());

System.out.println((new StringBuilder()).append("\u679A\u4E3E\u5E8F\u5217\u5316\u540E\u8BFB\u53D6\u5176\u4E2D\u7684\u5185\u5BB9\uFF1A").append(singleton2.getContent()).toString());

System.out.println((new StringBuilder()).append("\u679A\u4E3E\u5E8F\u5217\u5316\u524D\u540E\u4E24\u4E2A\u662F\u5426\u540C\u4E00\u4E2A\uFF1A").append(singleton1 == singleton2).toString());

Constructor constructor = com/singleton/SerEnumSingleton.getDeclaredConstructor(new Class[0]);

constructor.setAccessible(true);

SerEnumSingleton singleton3 = (SerEnumSingleton)constructor.newInstance(new Object[0]);

System.out.println((new StringBuilder()).append("\u53CD\u5C04\u540E\u8BFB\u53D6\u5176\u4E2D\u7684\u5185\u5BB9\uFF1A").append(singleton3.getContent()).toString());

System.out.println((new StringBuilder()).append("\u53CD\u5C04\u524D\u540E\u4E24\u4E2A\u662F\u5426\u540C\u4E00\u4E2A\uFF1A").append(singleton1 == singleton3).toString());

}

public static final SerEnumSingleton INSTANCE;

private String content;

private static final SerEnumSingleton $VALUES[];

static

{

INSTANCE = new SerEnumSingleton("INSTANCE", 0);

$VALUES = (new SerEnumSingleton[] {

INSTANCE

});

}

}

通过反编译后代码我们可以看到,ublic final class T extends Enum,说明,当我们使用enmu来定义一个枚举类型的时候,编译器会自动帮我们创建一个final类型的类继承Enum类,所以枚举类型不能被继承。

1. 枚举单例写法简单

2. 线程安全&懒加载

代码中 INSTANCE 变量被 public static final 修饰,因为static类型的属性是在类加载之后初始化的,JVM可以保证线程安全;且Java类是在引用到的时候才进行类加载,所以枚举单例也有懒加载的效果。

3. 枚举自己能避免序列化攻击

为了保证枚举类型像Java规范中所说的那样,每一个枚举类型极其定义的枚举变量在JVM中都是唯一的,在枚举类型的序列化和反序列化上,Java做了特殊的规定。

在序列化的时候Java仅仅是将枚举对象的name属性输出到结果中,反序列化的时候则是通过java.lang.Enum的valueOf方法来根据名字查找枚举对象。同时,编译器是不允许任何对这种序列化机制的定制,因此禁用了writeObject、readObject、readObjectNoData、writeReplace和readResolve等方法。 我们看一下Enum类的valueOf方法:

public static > T valueOf(Class enumType, String name) {

T result = enumType.enumConstantDirectory().get(name);

if (result != null)

return result;

if (name == null)

throw new NullPointerException("Name is null");

throw new IllegalArgumentException(

"No enum constant " + enumType.getCanonicalName() + "." + name);

}

从代码中可以看到,代码会尝试从调用enumType这个Class对象的enumConstantDirectory()方法返回的map中获取名字为name的枚举对象,如果不存在就会抛出异常。再进一步跟到enumConstantDirectory()方法,就会发现到最后会以反射的方式调用enumType这个类型的values()静态方法,也就是上面我们看到的编译器为我们创建的那个方法,然后用返回结果填充enumType这个Class对象中的enumConstantDirectory属性。所以,JVM对序列化有保证。

4. 枚举能够避免反射攻击,因为反射不支持创建枚举对象

Constructor类的 newInstance方法中会判断是否为 enum,若是会抛出异常

@CallerSensitive

public T newInstance(Object ... initargs) throws InstantiationException, IllegalAccessException, IllegalArgumentException, InvocationTargetException {

if (!override) {

if (!Reflection.quickCheckMemberAccess(clazz, modifiers)) {

Class caller = Reflection.getCallerClass();

checkAccess(caller, clazz, null, modifiers);

}

}

// 不能为 ENUM,否则抛出异常:不能通过反射创建 enum 对象

if ((clazz.getModifiers() & Modifier.ENUM) != 0)

throw new IllegalArgumentException("Cannot reflectively create enum objects");

ConstructorAccessor ca = constructorAccessor;   // read volatile

if (ca == null) {

ca = acquireConstructorAccessor();

}

@SuppressWarnings("unchecked")

T inst = (T) ca.newInstance(initargs);

return inst;

}

单例模式总结

单例模式作为一种目标明确、结构简单、理解容易的设计模式,在软件开发中使用频率相当高,在很多应用软件和框架中都得以广泛应用。

单例模式提供了对唯一实例的受控访问。

由于在系统内存中只存在一个对象,因此可以节约系统资源,对于一些需要频繁创建和销毁的对象,单例模式可以提高系统的性能。

允许可变数目的实例。基于单例模式我们可以进行扩展,使用与单例控制相似的方法来获得指定个数的对象实例,既节省系统资源,又解决了单例单例对象共享过多有损性能的问题。

由于单例模式中没有抽象层,因此单例类的扩展有很大的困难。

单例类的职责过重,在一定程度上违背了 "单一职责原则"。

如果实例化的共享对象长时间不被利用,系统可能会认为它是垃圾,会自动销毁并回收资源,下次利用时又将重新实例化,这将导致共享的单例对象状态的丢失。

系统只需要一个实例对象,如系统要求提供一个唯一的序列号生成器或资源管理器,或者需要考虑资源消耗太大而只允许创建一个对象。

客户调用类的单个实例只允许使用一个公共访问点,除了该公共访问点,不能通过其他途径访问该实例。

单例模式的典型应用

JDK Runtime类代表着Java程序的运行时环境,每个Java程序都有一个Runtime实例,该类会被自动创建,我们可以通过 Runtime.getRuntime() 方法来获取当前程序的Runtime实例。一旦得到了一个当前的Runtime对象的引用,就可以调用Runtime对象的方法去控制Java虚拟机的状态和行为。

Runtime 应用了饿汉式单例模式

public class Runtime {

private static Runtime currentRuntime = new Runtime();

public static Runtime getRuntime() {

return currentRuntime;

}

private Runtime() {

}

//....

}

API 介绍

addShutdownHook(Thread hook) 注册新的虚拟机来关闭挂钩。

availableProcessors() 向 Java 虚拟机返回可用处理器的数目。

exec(String command) 在单独的进程中执行指定的字符串命令。

exec(String[] cmdarray) 在单独的进程中执行指定命令和变量。

exec(String[] cmdarray, String[] envp) 在指定环境的独立进程中执行指定命令和变量。

exec(String[] cmdarray, String[] envp, File dir) 在指定环境和工作目录的独立进程中执行指定的命令和变量。

exec(String command, String[] envp) 在指定环境的单独进程中执行指定的字符串命令。

exec(String command, String[] envp, File dir) 在有指定环境和工作目录的独立进程中执行指定的字符串命令。

exit(int status) 通过启动虚拟机的关闭序列,终止当前正在运行的 Java 虚拟机。

freeMemory() 返回 Java 虚拟机中的空闲内存量。

gc() 运行垃圾回收器。

getRuntime() 返回与当前 Java 应用程序相关的运行时对象。

halt(int status) 强行终止目前正在运行的 Java 虚拟机。

load(String filename) 加载作为动态库的指定文件名。

loadLibrary(String libname) 加载具有指定库名的动态库。

maxMemory() 返回 Java 虚拟机试图使用的最大内存量。

removeShutdownHook(Thread hook) 取消注册某个先前已注册的虚拟机关闭挂钩。

runFinalization() 运行挂起 finalization 的所有对象的终止方法。

totalMemory() 返回 Java 虚拟机中的内存总量。

traceInstructions(on) 启用/禁用指令跟踪。

traceMethodCalls(on) 启用/禁用方法调用跟踪。

Desktop 类允许 Java 应用程序启动已在本机桌面上注册的关联应用程序,以处理 URI 或文件。支持的操作包括:

打开浏览器: 启动用户默认浏览器来显示指定的 URI;

打开邮件客户端: 启动带有可选 mailto URI 的用户默认邮件客户端;

打开文件/文件夹: 启动已注册的应用程序,以打开、编辑 或 打印 指定的文件。

Desktop 通过一个容器来管理单例对象

public class Desktop {

// synchronized 同步方法

public static synchronized Desktop getDesktop(){

if (GraphicsEnvironment.isHeadless()) throw new HeadlessException();

if (!Desktop.isDesktopSupported()) {

throw new UnsupportedOperationException("Desktop API is not " + "supported on the current platform");

}

sun.awt.AppContext context = sun.awt.AppContext.getAppContext();

Desktop desktop = (Desktop)context.get(Desktop.class); // 获取单例对象

// 存在则返回,不存在则创建,创建后put进容器

if (desktop == null) {

desktop = new Desktop();

context.put(Desktop.class, desktop);

}

return desktop;

}

AppContext 中有一个 HashMap 对象table,是实际的容器对象

private final Map table = new HashMap();

AbstractFactoryBean 类

public final T getObject() throws Exception {

if (this.isSingleton()) {

return this.initialized ? this.singletonInstance : this.getEarlySingletonInstance();

} else {

return this.createInstance();

}

}

private T getEarlySingletonInstance() throws Exception {

Class[] ifcs = this.getEarlySingletonInterfaces();

if (ifcs == null) {

throw new FactoryBeanNotInitializedException(this.getClass().getName() + " does not support circular references");

} else {

if (this.earlySingletonInstance == null) {

// 通过代理创建对象

this.earlySingletonInstance = Proxy.newProxyInstance(this.beanClassLoader, ifcs, new AbstractFactoryBean.EarlySingletonInvocationHandler());

}

return this.earlySingletonInstance;

}

}

ErrorContext 类,通过 ThreadLocal 管理单例对象,一个线程一个ErrorContext对象,ThreadLocal可以保证线程安全

public class ErrorContext {

private static final ThreadLocal LOCAL = new ThreadLocal();

private ErrorContext() {

}

public static ErrorContext instance() {

ErrorContext context = LOCAL.get();

if (context == null) {

context = new ErrorContext();

LOCAL.set(context);

}

return context;

}

//...

}

参考:

http://www.hollischuang.com/archives/197

https://www.cnblogs.com/chiclee/p/9097772.html

https://blog.csdn.net/abc123lzf/article/details/82318148

后记

欢迎评论、转发、分享,您的支持是我最大的动力

更多内容可访问我的个人博客:http://laijianfeng.org

关注【小旋锋】微信公众号,及时接收博文推送

任务调度 Java

版权声明:本文内容由网络用户投稿,版权归原作者所有,本站不拥有其著作权,亦不承担相应法律责任。如果您发现本站中有涉嫌抄袭或描述失实的内容,请联系我们jiasou666@gmail.com 处理,核实后本网站将在24小时内删除侵权内容。

上一篇:在120篇系列专栏中,才能学会 python beautifulsoup4 模块,7000字博客+爬第九工场网
下一篇:把Zookeeper说清楚
相关文章