单例模式、一致性哈希、redis防止数据丢失


单例模式

被问了一个问题,怎么验证单例模式。想半天没想出来,记录一下。

不考虑多线程

public class Singleton {
    private static Singleton singleton;
    // 将构造函数私有化,只能通过getInstance访问实例
    private Singleton() {
        
    }
    public static Singleton getInstance() {
        if(singleton==null) {
            singleton = new Singleton();
        }
        return singleton;
    }
}

测试代码

private static Singleton singleton;

public static Singleton getInstance() throws InterruptedException {
        if(singleton==null) {
            // 为了方便验证,手动加一个随机延迟
            Thread.sleep(new Random().nextInt(500));
            singleton = new Singleton();
        }
        return singleton;
    }

public static void testSingleton() throws InterruptedException {
    // =======1======见下文
    final CountDownLatch latch = new CountDownLatch(1000);
    final ConcurrentHashMap<Singleton,Integer> map = new ConcurrentHashMap<>();
    for(int i=0;i<1000;i++) {
        new Thread(new Runnable() {
            @Override
            public void run() {
                Singleton s = null;
                try {
                    s = Singleton.getInstance();
                    // ========2=======见下文
                    synchronized (s) {
                        map.put(s,map.getOrDefault(s,0)+1);
                    }
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }

                latch.countDown();
            }
        }).start();
    }
    latch.await();
    for(Singleton s:map.keySet()) {
        System.out.println(s+":"+map.get(s));
    }
    System.out.println("done");
}

public static void main(String[] args) throws InterruptedException {
    Singleton.testSingleton();
}

====2=====处用final

final变量,如果是基本类型,数值在初始化之后不能更改,如果是引用类型,在初始化之后便不能指向另一个对象。

====2=====处原本的想法是:

map.put(s,map.getOrDefault(s,0)+1);

但是这种实际是两个操作,就算用DCL,结果也不是1000

leetcode.Singleton@2464496:904
done

可以加个对象锁,s有冲突也肯定是同一个实例对象

synchronized (s) {
    map.put(s,map.getOrDefault(s,0)+1);
}

结果是:

leetcode.Singleton@5a344135:1
...
leetcode.Singleton@68ee5860:303
leetcode.Singleton@7449d4b:1
leetcode.Singleton@660e745c:3
leetcode.Singleton@7bc6d1d0:1
...
leetcode.Singleton@7995ef7f:1
leetcode.Singleton@2bef47c3:210
leetcode.Singleton@3a68c2d8:107
leetcode.Singleton@22f26843:1
leetcode.Singleton@6e311d43:1
...
leetcode.Singleton@65666f5a:1
leetcode.Singleton@2ea64cd8:130
done

很明显,不是线程安全的。

双重校验锁

加延时是为了测试并发情况,模拟被打断。

private static Singleton singleton;

public static Singleton getInstance() throws InterruptedException {
    if(singleton==null) {
        //Thread.sleep(new Random().nextInt(500));
        synchronized (Singleton.class) {
            if(singleton==null) {
                //Thread.sleep(new Random().nextInt(500)); 
                singleton = new Singleton(); //1
            }
        }

    }
    return singleton;
}

结果是:

leetcode.Singleton@5caf9db6:1000
done

隐患

1处代码,顺序是:

  • 分配内存(有房了)
  • 初始化(装修了)
  • 将对象指向刚分配的空间(住进去了)

但是有些编译器为了性能,会进行重排序,也就是:

  • 分配内存(有房了)
  • 将对象指向刚分配的空间(住进去了)
  • 初始化(装修了)

这种重排序情况,如果线程A到了对象指向内存空间阶段,但还没初始化,另一个线程B看到singleton不为null,直接返回了,这时就会出现问题。所以要声明为volatile,禁止指令重排序。

双重校验锁改进

private static volatile Singleton singleton;

public static Singleton getInstance() throws InterruptedException {
    if(singleton==null) {
        synchronized (Singleton.class) {
            if(singleton==null) { 
                singleton = new Singleton(); 
            }
        }

    }
    return singleton;
}

静态内部类

// 静态内部类单例模式
public class Singleton {
    private Singleton() {}
    private static class InstanceHolder {
        private final static Singleton instance = new Singleton();
    }

    public static Singleton getInstance() {
        return InstanceHolder.instance;
    }
    // 测试效果
    // interview1.Singleton@4a1a7268:1000
    // done
}

外部类加载的时候不会立即加载内部类。

类的加载时机(如果未加载):

  • new或者读取设置静态字段,调用静态方法时。
  • 反射调用时
  • 初始化一个类时,如果父类未加载,会先加载父类。
  • 虚拟机启动时,指定main()方法的主类、
  • java.lang.invoke.MethodHandle实例最后的解析结果的方法句柄(不了解)

在上面例子中,只有getInstance方法被调用时,InstanceHandler才在Singleton的运行时常量池里,把符号引用替换为直接引用,INSTANCE才被真正创建。

INSTANCE创建过程是线程安全的。

缺点就是:以静态内部类形式创建单例,无法传参。

破坏单例模式

public static void testSingletonReflect() throws InterruptedException {
    final CountDownLatch latch = new CountDownLatch(1000);
    ConcurrentHashMap<Singleton, Integer> map = new ConcurrentHashMap<>();

    for (int i = 0; i < 1000; i++) {
        new Thread(new Runnable() {
            @Override
            public void run() {
                Constructor<Singleton> constructor = null;
                try {
                    //constructor = Singleton.class.getConstructor();
                    // getConstructor()得不到类的私有构造器
                    // getDecl...可以,但是要setAccessible(true)才能获取
                    constructor = Singleton.class.getDeclaredConstructor();
                    constructor.setAccessible(true);
                    Singleton s = constructor.newInstance();
                    synchronized (s) {
                        map.put(s,map.getOrDefault(s,0)+1);
                    }
                } catch (Exception e) {
                    e.printStackTrace();
                }
            }
        }).start();
        latch.countDown();
    }
    latch.await();
    for(Singleton s:map.keySet()) {
        System.out.println(s+":"+map.get(s));
    }
    System.out.println("testSingletonReflect() done");
}

j结果:

interview1.Singleton@6a8a5e8b:1
interview1.Singleton@53925f91:1
interview1.Singleton@6519891a:1
...

这些方法的单例模式可以被反射破坏。

对比

  • 饿汉式:初始化的时候就加载了,不能实现懒加载,造成内存空间浪费。
  • 懒汉式:可以DCL加锁解决。
  • 静态内部类:传参问题。

==枚举法==

enum Type {
    A,B,C,D;

    static int value = 3;
    public static int getValue() {
        return value;
    }
}

可以看做一个类,ABCD是它的实例,enum的构造方法是私有的。

通过反射要绕过的话会报错:

java.lang.NoSuchMethodException: interview1.SingletonDemo.<init>()

反射在通过newInstance创建对象时,会检查该类是否ENUM修饰,如果是则抛出异常,反射失败。

enum SingletonDemo {
    INSTANCE;
    public static SingletonDemo getInstance() {
        return INSTANCE;
    }

    private String name;
    public void sayHi(String n) {
        name = n;
        System.out.println("hi "+name);
    }
}

序列化问题

序列化的时候只将 INSTANCE 这个名称输出,反序列化的时候再通过这个名称,查找对于的枚举类型,因此反序列化后的实例也会和之前被序列化的对象实例相同。

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

一致性哈希

image-20220312233133941

image-20220312233431558

增加虚拟结点,这样宕机或者加机器的时候,移动的结点也更能分散到其他的机器上。

Redis防止数据丢失

RDB

RDB:数据快照。通过执行save(前台阻塞)或者bgsave(后台)生成本地快照,是压缩写入的,所以体积要比实例内存小。加载的时候恢复也相对快些。问题:数据补全(某一时刻的快照)、代价较大,消耗大量CPU和内存资源。

后台保存的时候回生成一个子进程,扫描保存内存数据。写时拷贝技术。没有改动的照常,共享内存地址空间,有改动的使用新的内存地址。通过这样写时拷贝技术减少内存复制。

AOF

AOF:实时追加命令的日志文件。

文件刷盘时机:

  • 每次写入都刷,性能影响最大,磁盘IO,数据安全性最高。
  • 1s一刷,对性能影响较小,结点宕机最多丢失一秒的数据。
  • 按照操作系统的机制来刷盘。

有AOF重写机制,因为指令越多越来越来多,文件越来越大,通过扫描整个实例的数据,重新生成一个AOF文件。

总结

通常可以选择每秒刷盘的机制,既能保证良好的写入性能,在实例宕机时最多丢失一秒的数据,做到性能和安全的平衡。

顺便:redis分布式锁问题

setnx key value。要设置过期时间,否则可能获取锁的线程崩溃,永远锁住。

设置登录密码:/etc/redis.conf下面

# requirepass foobared取消注释,改成自己设置的密码

vim 右键visual问题:set mouse-=a

hqinglau@centos:~$ redis-cli
127.0.0.1:6379> setnx lock Java # set if not exist
(integer) 1
127.0.0.1:6379> setnx lock C++ # 已经有值了,设置失败
(integer) 0
127.0.0.1:6379> ttl lock  #查询过期时间
(integer) -1

设置过期时间:

127.0.0.1:6379> expire lock 10
(integer) 1
127.0.0.1:6379> ttl lock
(integer) 7
127.0.0.1:6379> ttl lock
(integer) 6
127.0.0.1:6379> ttl lock
(integer) 1
127.0.0.1:6379> ttl lock
(integer) -2
127.0.0.1:6379> ttl lock
(integer) -2
127.0.0.1:6379> get lock
(nil)

但是这样就是两个步骤了,不是原子操作了,可以一步完成。setex,会覆写旧值。

127.0.0.1:6379> setex lock 10 java
OK
127.0.0.1:6379> get lock
"java"
127.0.0.1:6379> #10s later
(error) ERR unknown command '#10s'
127.0.0.1:6379> get lock
(nil)

兼容操作:

127.0.0.1:6379> set lock java nx ex 10
OK
127.0.0.1:6379> get lock
"java"
127.0.0.1:6379> get lock
(nil)

redis分布式锁释放:

首先不能直接释放,可能不是自己加的锁;也不能判断value然后释放,因为判断和释放不是原子操作,中间可能被打断。可以用手动封装一个锁把它变成原子操作。

参考文献

Java中的双重检查锁(double checked locking)

一致性哈希算法