一、单例模式

1.1 饱汉模式

饱汉是变种最多的单例模式。我们从饱汉出发,通过其变种逐渐了解实现单例模式时需要关注的问题。

基础的饱汉

饱汉,即已经吃饱,不着急再吃,饿的时候再吃。所以他就先不初始化单例,等第一次使用的时候再初始化,即“懒加载”。

// 饱汉
// UnThreadSafe
public class Singleton1 {
  private static Singleton1 singleton = null;
  private Singleton1() {
  }
  public static Singleton1 getInstance() {
    if (singleton == null) {
      singleton = new Singleton1();
    }
    return singleton;
  }
}

饱汉模式的核心就是懒加载。好处是更启动速度快、节省资源,一直到实例被第一次访问,才需要初始化单例。基础饱汉可读性好。
小坏处是写起来麻烦,大坏处是线程不安全,if语句存在竞态条件,只适用于单线程环境下。

单锁的饱汉
// 饱汉
// ThreadSafe
public class Singleton1_1 {
  private static Singleton1_1 singleton = null;
  private Singleton1_1() {
  }
  public synchronized static Singleton1_1 getInstance() {
    if (singleton == null) {
      singleton = new Singleton1_1();
    }
    return singleton;
  }
}

写起来简单,且绝对线程安全;
坏处是并发性能极差,事实上完全退化到了串行。单例只需要初始化一次,但就算初始化以后,synchronized的锁也无法避开,从而getInstance()完全变成了串行操作。性能不敏感的场景建议使用。

双锁的饱汉
// 饱汉
// ThreadSafe 双重检查锁
public class Singleton1_3 {
  private static volatile Singleton1_3 singleton = null;

  public int f1 = 1;   // 触发部分初始化问题
  public int f2 = 2;
  private Singleton1_3() {
  }
  public static Singleton1_3 getInstance() {
    if (singleton == null) {
      synchronized (Singleton1_3.class) {
        // must be a complete instance
        if (singleton == null) {
          singleton = new Singleton1_3();
        }
      }
    }
    return singleton;
  }
}
  1. 第一次校验:也就是第一个if(singleton==null),这个是为了代码提高代码执行效率,由于单例模式只要一次创建实例即可,所以当创建了一个实例之后,再次调用getInstance方法就不必要进入同步代码块,不用竞争锁。直接返回前面创建的实例即可。

  2. 第二次校验:也就是第二个if(singleton==null),这个校验是防止二次创建实例,假如有一种情况,当singleton还未被创建时,线程t1调用getInstance方法,由于第一次判断singleton==null,此时线程t1准备继续执行,但是由于资源被线程t2抢占了,此时t2页调用getInstance方法,同样的,由于singleton并没有实例化,t2同样可以通过第一个if,然后继续往下执行,同步代码块,第二个if也通过,然后t2线程创建了一个实例singleton。此时t2线程完成任务,资源又回到t1线程,t1此时也进入同步代码块,如果没有这个第二个if,那么,t1就也会创建一个singleton实例,那么,就会出现创建多个实例的情况,但是加上第二个if,就可以完全避免这个多线程导致多次创建实例的问题。

  3. volatile关键字可以防止jvm指令重排优化,因为 singleton = new Singleton() 这句话可以分为三步:

    1. 为 singleton 分配内存空间;
    2. 初始化 singleton;
    3. 将 singleton 指向分配的内存空间。

但是由于JVM具有指令重排的特性,执行顺序有可能变成 1-3-2。 指令重排在单线程下不会出现问题,但是在多线程下会导致一个线程获得一个未初始化的实例。例如:线程T1执行了1和3,此时T2调用 getInstance() 后发现 singleton 不为空,因此返回 singleton, 但是此时的 singleton 还没有被初始化。

1.2 饿汉模式

与饱汉相对,饿汉很饿,只想着尽早吃到。所以他就在最早的时机,即类加载时初始化单例,以后访问时直接返回即可。

// 饿汉
// ThreadSafe
public class Singleton2 {
  private static final Singleton2 singleton = new Singleton2();
  private Singleton2() {
  }
  public static Singleton2 getInstance() {
    return singleton;
  }
}

饿汉的好处是天生的线程安全(得益于类加载机制),写起来超级简单,使用时没有延迟;坏处是有可能造成资源浪费(如果类加载后就一直不使用单例的话)。
值得注意的时,单线程环境下,饿汉与饱汉在性能上没什么差别;但多线程环境下,由于饱汉需要加锁,饿汉的性能反而更优。

1.3 内部类模式

我们既希望利用饿汉模式中静态变量的方便和线程安全;又希望通过懒加载规避资源浪费。
Holder模式满足了这两点要求:核心仍然是静态变量,足够方便和线程安全;通过静态的Holder类持有真正实例,间接实现了懒加载。

// Holder模式
// ThreadSafe
public class Singleton3 {
  private static class SingletonHolder {
    private static final Singleton3 singleton = new Singleton3();
    private SingletonHolder() {
    }
  }
  private Singleton3() {
  }

  public static Singleton3 getInstance() {
    return SingletonHolder.singleton;
  }
}

相对于饿汉模式,Holder模式仅增加了一个静态内部类的成本,与饱汉的变种3效果相当(略优),都是比较受欢迎的实现方式。同样建议考虑

1.4 枚举模式

用枚举实现单例模式,相当好用,但可读性是不存在的。
将枚举的静态成员变量作为单例的实例:

// 枚举
// ThreadSafe
public enum Singleton4 {
  SINGLETON;
}

代码量比饿汉模式更少。
但用户只能直接访问实例Singleton4.SINGLETON——事实上,这样的访问方式作为单例使用也是恰当的,只是牺牲了静态工厂方法的优点,如无法实现懒加载。

语法糖

Java的枚举是一个“丑陋但好用的语法糖”。
通过反编译打开语法糖,就看到了枚举类型的本质,简化如下:

// 枚举
// ThreadSafe
public class Singleton4 extends Enum<Singleton4> {
  ...
  public static final Singleton4 SINGLETON = new Singleton4();
  ...
}

本质上和饿汉模式相同,区别仅在于公有的静态成员变量。

1.5 优点

在单例模式中,活动的单例只有一个实例,对单例类的所有实例化得到的都是相同的一个实例。这样就 防止其它对象对自己的实例化,确保所有的对象都访问一个实例
由于在系统内存中只存在一个对象,因此可以 节约系统资源,当 需要频繁创建和销毁的对象时单例模式无疑可以提高系统的性能。
避免对共享资源的多重占用。

1.6 缺点

不适用于变化的对象,如果同一类型的对象总是要在不同的用例场景发生变化,单例就会引起数据的错误,不能保存彼此的状态。
由于单利模式中没有抽象层,因此单例类的扩展有很大的困难。
如果实例化的对象长时间不被利用,系统会认为是垃圾而被回收,这将导致对象状态的丢失。

1.7 注意事项

使用时不能用反射模式创建单例,否则会实例化一个新的对象
使用懒单例模式时注意线程安全问题

1.8 适用场景

单例模式只允许创建一个对象,节省内存,加快对象访问速度。

需要频繁实例化然后销毁的对象。
创建对象时耗时过多或者耗资源过多,但又经常用到的对象。
有状态的工具类对象,如频繁访问数据库或文件的对象。
2.5 应用场景
每台计算机有若干个打印机,但只能有一个PrinterSpooler,以避免两个打印作业同时输出到打印机。
Windows的Task Manager(任务管理器),Recycle Bin(回收站)。
应用程序的日志应用,一般都何用单例模式实现,这一般是由于共享的日志文件一直处于打开状态,因为只能有一个实例去操作,否则内容不好追加。
数据库连接池的设计一般也是采用单例模式,节省打开或者关闭数据库连接所引起的效率损耗
操作系统的文件系统,也是大的单例模式实现的具体例子,一个操作系统只能有一个文件系统。

1.9 Ctroller默认单例

SpringMVC的@Ctroller默认是单例的,除非在类上加上@Score("prototype")。

  1. 单例的类其普通属性与静态属性都会被共享,所以尽量不要在controller里面去定义属性
  2. 在特殊情况需要定义属性的时候,在类上面加上注解@Scope("prototype")改为多例的模式.
  3. 只要controller中不定义属性,那么单例完全是安全的。
  4. springmvc这样设计主要的原因也是为了提高程序的性能和减少维护成本,以后程序的维护只针对业务的维护就行。属性定义多了,就不知道哪个方法用了这个属性,对以后程序的维护还是很麻烦的。

二、Redis实现分布式锁

分布式锁常见的三种实现方式:

  1. 基于数据库的分布式锁;
  2. 基于Redis的分布式锁;
  3. 基于ZooKeeper的分布式锁。

整体性能对比:缓存 > Zookeeper、etcd > 数据库。

2.1 基于数据库

基于表记录

要实现分布式锁,最简单的方式可能就是直接创建一张锁表,然后通过操作该表中的数据来实现了。
当我们想要获得锁的时候,就可以在该表中增加一条记录,想要释放锁的时候就删除这条记录。

CREATE TABLE `database_lock` (
	`id` BIGINT NOT NULL AUTO_INCREMENT,
	`resource` int NOT NULL COMMENT '锁定的资源',
	`description` varchar(1024) NOT NULL DEFAULT "" COMMENT '描述',
	PRIMARY KEY (`id`),
	-- resource字段做了唯一性约束,这样如果有多个请求同时提交到数据库的话,数据库可以保证只有一个操作可以成功
	UNIQUE KEY `uiq_idx_resource` (`resource`) 
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='数据库分布式锁表';

-- 当我们想要获得锁时,可以插入一条数据:
INSERT INTO database_lock(resource, description) VALUES (1, 'lock');
注意事项
  1. 这种锁没有失效时间,一旦释放锁的操作失败就会导致锁记录一直在数据库中,其它线程无法获得锁。这个缺陷也很好解决,比如可以做一个定时任务去定时清理。
  2. 这种锁的可靠性依赖于数据库。建议设置备库,避免单点,进一步提高可靠性。
  3. 这种锁是非阻塞的,因为插入数据失败之后会直接报错,想要获得锁就需要再次操作。如果需要阻塞式的,可以弄个for循环、while循环之类的,直至INSERT成功再返回。
  4. 这种锁也是非可重入的,因为同一个线程在没有释放锁之前无法再次获得锁,因为数据库中已经存在同一份记录了。想要实现可重入锁,可以在数据库中添加一些字段,比如获得锁的主机信息、线程信息等,那么在再次获得锁的时候可以先查询数据,如果当前的主机信息和线程信息等能被查到的话,可以直接把锁分配给它。
乐观锁

系统认为数据的更新在大多数情况下是不会产生冲突的,只在数据库更新操作提交的时候才对数据作冲突检测。如果检测的结果出现了与预期数据不一致的情况,则返回失败信息。
乐观锁大多数是基于数据版本(version)的记录机制实现的。何谓数据版本号?即为表增加一个“version”字段标识。更新时,对此版本号加1。在更新过程中,会对版本号进行比较,如果是一致的,没有发生改变,则会成功执行本次操作;如果版本号不一致,则会更新失败。
其实,借助更新时间戳(updated_at)也可以实现乐观锁。

注意事项
  1. 乐观锁的优点比较明显,由于在检测数据冲突时并不依赖数据库本身的锁机制,不会影响请求的性能,当产生并发且并发量较小的时候只有少部分请求会失败。
  2. 缺点是需要对表的设计增加额外的字段,增加了数据库的冗余。
  3. 另外,当应用并发量高的时候,version值在频繁变化,则会导致大量请求失败,影响系统的可用性。
  4. 数据库锁都是作用于同一行数据记录上,这就导致一个明显的缺点,在一些特殊场景,如大促、秒杀等活动开展的时候,大量的请求同时请求同一条记录的行锁,会对数据库产生很大的写压力。

所以综合数据库乐观锁的优缺点,乐观锁比较适合并发量不高,并且写操作不频繁的场景。

悲观锁

除了可以通过增删操作数据库表中的记录以外,我们还可以借助数据库中自带的锁来实现分布式锁。在查询语句后面增加FOR UPDATE,数据库会在查询过程中给数据库表增加悲观锁,也称排他锁。
当某条记录被加上悲观锁之后,其它线程也就无法再改行上增加悲观锁。

注意事项
  1. 在使用悲观锁的同时,我们需要注意一下锁的级别。MySQL InnoDB引起在加锁的时候,只有明确地指定主键(或索引)的才会执行行锁 (只锁住被选取的数据),否则MySQL 将会执行表锁(将整个数据表单给锁住)。
  2. 在使用悲观锁时,我们必须关闭MySQL数据库的自动提交属性(参考下面的示例),因为MySQL默认使用autocommit模式,也就是说,当你执行一个更新操作后,MySQL会立刻将结果进行提交。
  3. 在悲观锁中,每一次行数据的访问都是独占的,只有当正在访问该行数据的请求事务提交以后,其他请求才能依次访问该数据,否则将阻塞等待锁的获取。
  4. 悲观锁可以严格保证数据访问的安全。但是缺点也明显,即每次请求都会额外产生加锁的开销且未获取到锁的请求将会阻塞等待锁的获取,在高并发环境下,容易造成大量请求阻塞,影响系统可用性。
  5. 另外,悲观锁使用不当还可能产生死锁的情况。

2.2 基于Redis

Redis要实现分布式锁,以下条件应该得到满足

  1. 互斥性,在任意时刻,只有一个客户端能持有锁。
  2. 不能死锁,客户端在持有锁的期间崩溃而没有主动解锁,也能保证后续其他客户端能加锁。
  3. 容错性,只要大部分的Redis节点正常运行,客户端就可以加锁和解锁。
单节点
实现

可以直接通过 set key value px milliseconds nx 命令实现加锁, 通过Lua脚本实现解锁。

//获取锁(unique_value可以是UUID等)
SET resource_name unique_value NX PX  30000

//释放锁(lua脚本中,一定要比较value,防止误解锁)
//KEYS和ARGV分别是以集合方式传入的参数,对应上面的resource_name ,unique_value
if redis.call("get",KEYS[1]) == ARGV[1] then
    return redis.call("del",KEYS[1])
else
    return 0
end
注意
  1. set 命令要用 set key value px milliseconds nx,替代 setnx + expire 需要分两次执行命令的方式,保证了原子性,
  2. 由于我们对锁设置了过期时间,即使锁的持有者后续发生崩溃而没有解锁,锁也会因为到了过期时间而自动解锁(即key被删除),不会发生死锁。
  3. value 要具有唯一性,可以使用UUID.randomUUID().toString()方法生成,用来标识这把锁是属于哪个请求加的,在解锁的时候就可以有依据;
  4. 释放锁时要验证 value 值,防止误解锁;
  5. 通过 Lua 脚本来避免 Check And Set 模型的并发问题,因为在释放锁的时候因为涉及到多个Redis操作 (利用了eval命令执行Lua脚本的原子性)
风险

如果存储锁对应key的那个节点挂了的话,就可能存在丢失锁的风险,导致出现多个客户端持有锁的情况,这样就不能实现资源的独享了。

  1. 客户端A从master获取到锁
  2. 在master将锁同步到slave之前,master宕掉了(Redis的主从同步通常是异步的)。
    主从切换,slave节点被晋级为master节点
  3. 客户端B取得了同一个资源被客户端A已经获取到的另外一个锁。导致存在同一时刻存不止一个线程获取到锁的情况。
多节点
redlock算法

这个场景是假设有一个 redis cluster,有 5 个 redis master 实例。然后执行如下步骤获取一把锁:

  1. 获取当前时间戳,单位是毫秒;
  2. 跟上面类似,轮流尝试在每个 master 节点上创建锁,过期时间较短,一般就几十毫秒;
  3. 尝试在大多数节点上建立一个锁,比如 5 个节点就要求是 3 个节点 n / 2 + 1;
  4. 客户端计算建立好锁的时间,如果建立 n / 2 + 1个锁的总时间小于超时时间,就算建立成功了;
  5. 要是锁建立失败了,那么就依次之前建立过的锁删除;
  6. 只要别人建立了一把分布式锁,你就得不断轮询去尝试获取锁。

image.png

Redisson实现

Redisson是一个在Redis的基础上实现的Java驻内存数据网格(In-Memory Data Grid)。是 Java 的 Redis 客户端之一,提供了一些 API 方便操作 Redis。
它不仅提供了一系列的分布式的Java常用对象,还实现了可重入锁(Reentrant Lock)、公平锁(Fair Lock、联锁(MultiLock)、 红锁(RedLock)、 读写锁(ReadWriteLock)等,还提供了许多分布式服务。
Redisson提供了使用Redis的最简单和最便捷的方法。Redisson的宗旨是促进使用者对Redis的关注分离(Separation of Concern),从而让使用者能够将精力更集中地放在处理业务逻辑上。

Redisson 分布式重入锁

Redisson 支持单点模式、主从模式、哨兵模式、集群模式,这里以单点模式为例:

// 1.构造redisson实现分布式锁必要的Config
Config config = new Config();
config.useSingleServer().setAddress("redis://127.0.0.1:5379").setPassword("123456").setDatabase(0);
// 2.构造RedissonClient
RedissonClient redissonClient = Redisson.create(config);
// 3.获取锁对象实例(无法保证是按线程的顺序获取到)
RLock rLock = redissonClient.getLock(lockKey);
try {
    /**
     * 4.尝试获取锁
     * waitTimeout 尝试获取锁的最大等待时间,超过这个值,则认为获取锁失败
     * leaseTime   锁的持有时间,超过这个时间锁会自动失效(值应设置为大于业务处理的时间,确保在锁有效期内业务能处理完)
     */
    boolean res = rLock.tryLock((long)waitTimeout, (long)leaseTime, TimeUnit.SECONDS);
    if (res) {
        //成功获得锁,在这里处理业务
    }
} catch (Exception e) {
    throw new RuntimeException("aquire lock fail");
}finally{
    //无论如何, 最后都要解锁
    rLock.unlock();
}

image.png
image.png

  1. RedissonLock是可重入的,并且考虑了失败重试,可以设置锁的最大等待时间,并减少了无效的锁申请,提升了资源的利用率。
  2. RedissonLock 同样没有解决 节点挂掉的时候,存在丢失锁的风险的问题。
  3. Redisson 提供了实现了redlock算法的 RedissonRedLock,RedissonRedLock 真正解决了单点失败的问题,代价是需要额外的为 RedissonRedLock 搭建Redis环境。

2.3 基于ZooKeeper

三、Object的常见方法

Java语言是一种单继承结构语言,Java中所有的类都有一个共同的祖先。这个祖先就是Object类。
如果一个类没有用extends明确指出继承于某个类,那么它默认继承Object类。
Object类是Java中所有类的基类。位于java.lang包中,一共有13个方法。
image.png

  1. Object(),构造方法。(非重点)
  2. registerNatives(),使JVM发现本机功能,可以用来命名C函数。(非重点)
  3. toString(),返回该对象的字符串表示。(非重点)。
  4. getClass(),用于获得运行时的类型。该方法返回的是此Object对象的类对象/运行时类对象Class。效果与Object.class相同。
  5. equals(),用来比较两个对象的内容是否相等。默认情况下(继承自Object类),equals和==是一样的,除非被覆写(override)了。
  6. hashCode(),该方法用来返回其所在对象的物理地址(哈希码值),常会和equals方法同时重写,确保相等的两个对象拥有相等的hashCode。
  7. clone(),用来另存一个当前存在的对象。只有实现了Cloneable接口才可以调用该方法,否则抛出CloneNotSupportedException异常。
  8. wait(),导致当前的线程等待,直到其他线程调用此对象的 notify() 方法或 notifyAll() 方法。
  9. notify()/notifyAll(),唤醒在此对象监视器上等待的单个/所有线程。
  10. finalize(),当垃圾回收器确定不存在对该对象的更多引用时,由对象的垃圾回收器调用此方法。

hashCode()和qruals()

1.1 作用

equals() 的作用是用来判断两个对象是否相等。
hashCode() 的作用是获取哈希码,也称为散列码;它实际上是返回一个int整数。这个哈希码的作用是确定该对象在哈希表中的索引位置。

1.2 处理规范
  1. 只要重写equals,就必须重写hashCode
  2. 因为Set存储的是不重复的对象,依据hashCode和equals进行判断,所以Set存储对象必须重写这两个方法。
  3. 如果自定义对象做Map的键,那么必须重写hashCode和equals
  4. String重写了hashCode和equals方法,所以可以直接用来作为key使用。
  5. 如果两个对象相等,那么它们的hashCode()值一定相同,如果两个对象hashCode()相等,它们并不一定相等,可能存在哈希冲突
    如果不会在HashSet, Hashtable, HashMap等等这些本质是散列表的数据结构中用到该类,hashCode() 没有任何作用

Q.E.D.

知识共享署名-非商业性使用-相同方式共享 4.0 国际许可协议