烟雨星空

烟雨星空 查看完整档案

北京编辑  |  填写毕业院校公众号  |  烟雨星空 编辑 www.starryskys.cn 编辑
编辑

目前从事Java开发,喜欢游戏,业余时间自学游戏开发。
个人公众号「烟雨星空」,关注可免费领取1000G的学习大礼包,含前端,Java,大数据,Python,人工智能等资料。

个人动态

烟雨星空 发布了文章 · 2020-10-31

教你如何使用github+jsDelivr搭建免费图床

前言

之前写了一篇文章,教你如何使用Typora+PicGo实现图片自动上传到图床

这里我用的是七牛图床,七牛图床有一定的免费使用量(没记错的话应该是10个G),如果你的存储量超过这个大小就需要付费使用了。除此之外,还需要维护一个备案过的域名,绑定一台云服务器。这些都需要一定的费用。

因此,对于白嫖党来说非常不友好。

今天,我就教大家用 “全球最大同性交友网站” github 并搭配 jsDelivr 开源 CDN 来搭建一个免费图床。全程不需要任何费用哦,白嫖党欢呼吧~

正文

本文内容包括:

  • 创建一个 github 仓库
  • 使用 jsDelivr 免费 CDN 加速图片访问速度
  • 创建 Token
  • 使用 PicGo 配置 github 图床

创建 github 仓库

这里就跳过怎么注册 github 账号的步骤了,做技术的都晓得。

1、登录你的 github 账号,创建一个新的仓库。

2、然后填写仓库的资料,主要是仓库名,其他一般默认。

3、点击 create repository 后,跳到这个页面,就说明创建成功了。

然后可以上传一张图片试一下。不过,有可能你会遇到在 github 上看到的图片是裂开的情况。

只需要在电脑的 hosts 文件中添加以下代码即可。 windows 下的 hosts文件 目录在 C:\\Windows\\System32\\drivers\\etc 。(注意要以管理员权限打开) mac 下为 /etc/hosts

# GitHub Start
52.74.223.119 github.com
192.30.253.119 gist.github.com
54.169.195.247 api.github.com
185.199.111.153 assets-cdn.github.com
151.101.76.133 raw.githubusercontent.com
151.101.108.133 user-images.githubusercontent.com
151.101.76.133 gist.githubusercontent.com
151.101.76.133 cloud.githubusercontent.com
151.101.76.133 camo.githubusercontent.com
151.101.76.133 avatars0.githubusercontent.com
151.101.76.133 avatars1.githubusercontent.com
151.101.76.133 avatars2.githubusercontent.com
151.101.76.133 avatars3.githubusercontent.com
151.101.76.133 avatars4.githubusercontent.com
151.101.76.133 avatars5.githubusercontent.com
151.101.76.133 avatars6.githubusercontent.com
151.101.76.133 avatars7.githubusercontent.com
151.101.76.133 avatars8.githubusercontent.com

然后回到你的图片仓库,刷新一下页面即可正常显示图片。

使用 jsDelivr 免费加速

其实,此时已经可以正常访问你仓库中的图片了。我这里以我创建好的仓库 myImages 为例。

要想访问仓库中的这个 test.png 图片,需要把链接地址中的 blob 改为 raw。即 https://github.com/starry-skys/myImages/raw/main/test.png 。或者在地址后拼接一段 ?raw=true,即 https://github.com/starry-skys/myImages/blob/main/test.png?raw=true

但是,我们发现,通过 github 直接访问图片,速度不是特别理想,毕竟服务器在国外。

因此,我们可以使用 jsDelivr 进行 CDN 加速。这是完全开源免费的。

使用方法,非常简单,即把图片地址链接域名改为 CDN 的域名。格式如下:

https://cdn.jsdelivr.net/gh/<你的github用户名>/<你的图床仓库名>@<仓库版本号>/图片的路径

还是以上边的 test.png 图片为例,仓库版本号直接用分支名,由于现在 github 主分支名字都叫 main 了,因此版本号写 main 。图片路径,是在仓库中的相对路径,因为我这里就在根目录,因此就是 test.png 。

最终地址为 https://cdn.jsdelivr.net/gh/starry-skys/myImages@main/test.png

其他说明,可参考 jsDelivr 官网介绍,jsDelivr 官网

配置 typora 自动上传到 github 图床

接下来,如果需要在 typora 中设置自动上传到 gtihub 图床,还需要做一些配置。

一、首先,在 github 上创建一个 token。

1、点击右上角账号上的 settings

2、然后左侧点击 developer settings ,再点击 personal access tokens ,然后点击 generate new token。

3、Note 用来说明你创建 token 的用途,然后 scopes 只需要选 repo 的所有选项即可。

4、最后拉到底部,点击 generate token ,即可成功。

5、找个地方记下这一串 token,等会需要用到。(如果没有记住,等再查看时就只能重新生成了)

二、打开 PicGo 配置 github 图床

在 PicGo 中,找到图床设置 -> GitHub图床。

  • 仓库名即为你的github账号/图片仓库名
  • 分支名就用默认的 main
  • Token 就填写刚才我们生成的 Token
  • 存储路径如果需要指定子目录可以填写例如 img/ 。我这里没有填,就会上传到我图片仓库的根目录。
  • 自定义域名就填写 jsDelivr 的域名,即图片访问地址,不包括图片路径的前半部分,我这里就是 https://cdn.jsdelivr.net/gh/starry-skys/myImages@main
  • 最后设为默认图床,下次在 typora 上传图片就会自动上传到 github 图床了。

至此,所有步骤就已经完成了,赶紧去尝试一下吧。

查看原文

赞 0 收藏 0 评论 0

烟雨星空 发布了文章 · 2020-10-24

面试官问我:创建线程有几种方式?我笑了

前言

多线程在面试中基本上已经是必问项了,面试官通常会从简单的问题开始发问,然后再一步一步的挖掘你的知识面。

比如,从线程是什么开始,线程和进程的区别,创建线程有几种方式,线程有几种状态,等等。

接下来自然就会引出线程池,Lock,Synchronized,JUC的各种并发包。然后就会引出 AQS、CAS、JMM、JVM等偏底层原理,一环扣一环。

这一节我们不聊其他的,只说创建线程有几种方式。

是不是感觉非常简单,不就是那个啥啥那几种么。

其实不然,只有我们给面试官解释清楚了,并加上我们自己的理解,才能在面试中加分。

正文

一般来说我们比较常用的有以下四种方式,下面先介绍它们的使用方法。然后,再说面试中怎样回答面试官的问题比较合适。

1、继承 Thread 类

通过继承 Thread 类,并重写它的 run 方法,我们就可以创建一个线程。

  • 首先定义一个类来继承 Thread 类,重写 run 方法。
  • 然后创建这个子类对象,并调用 start 方法启动线程。

2、实现 Runnable 接口

通过实现 Runnable ,并实现 run 方法,也可以创建一个线程。

  • 首先定义一个类实现 Runnable 接口,并实现 run 方法。
  • 然后创建 Runnable 实现类对象,并把它作为 target 传入 Thread 的构造函数中
  • 最后调用 start 方法启动线程。

3、实现 Callable 接口,并结合 Future 实现

  • 首先定义一个 Callable 的实现类,并实现 call 方法。call 方法是带返回值的。
  • 然后通过 FutureTask 的构造方法,把这个 Callable 实现类传进去。
  • 把 FutureTask 作为 Thread 类的 target ,创建 Thread 线程对象。
  • 通过 FutureTask 的 get 方法获取线程的执行结果。

4、通过线程池创建线程

此处用 JDK 自带的 Executors 来创建线程池对象。

  • 首先,定一个 Runnable 的实现类,重写 run 方法。
  • 然后创建一个拥有固定线程数的线程池。
  • 最后通过 ExecutorService 对象的 execute 方法传入线程对象。

到底有几种创建线程的方式?

那么问题来了,我这里举例了四种创建线程的方式,是不是说明就是四种呢?

我们先看下 JDK 源码中对 Thread 类的一段解释,如下图。

There are two ways to create a new thread of execution

翻译: 有两种方式可以创建一个新的执行线程

这里说的两种方式就对应我们介绍的前两种方式。

但是,我们会发现这两种方式,最终都会调用 Thread.start 方法,而 start 方法最终会调用 run 方法。

不同的是,在实现 Runnable 接口的方式中,调用的是 Thread 本类的 run 方法。我们看下它的源码,

这种方式,会把创建的 Runnable 实现类对象赋值给 target ,并运行 target 的 run 方法。

再看继承 Thread 类的方式,我们同样需要调用 Thread 的 start 方法来启动线程。由于子类重写了 Thread 类的 run 方法,因此最终执行的是这个子类的 run 方法。

所以,我们也可以这样说。在本质上,创建线程只有一种方式,就是构造一个 Thread 类(其子类其实也可以认为是一个 Thread 类)。

而构造 Thread 类又有两种方式,一种是继承 Thread 类,一种是实现 Runnable接口。其最终都会创建 Thread 类(或其子类)的对象。

再来看实现 Callable ,结合 Future 和 FutureTask 的方式。可以发现,其最终也是通过 new Thread(task) 的方式构造 Thread 类。

最后,在线程池中,我们其实是把创建和管理线程的任务都交给了线程池。而创建线程是通过线程工厂类 DefaultThreadFactory 来创建的(也可以自定义工厂类)。我们看下这个工厂类的具体实现。

它会给线程设置一些默认值,如线程名称,线程的优先级,线程组,是否是守护线程等。最后还是通过 new Thread() 的方式来创建线程的。

因此,综上所述。在回答这个问题的时候,我们可以说本质上创建线程就只有一种方式,就是构造一个 Thread 类。(此结论借鉴来源于 Java 并发编程 78 讲 -- 徐隆曦)

个人想法

但是,在这里我想对这个结论稍微提出一些疑问(若有不同见解,文末可留言交流~)。。。

个人认为,如果你要说有 1种、2种、3种、4种 其实也是可以的。重要的是,你要能说出你的依据,讲出它们各自的不同点和共同点。讲得头头是道,让面试官对你频频点头。。

说只有构造 Thread 类这一种创建线程方式,个人认为还是有些牵强。因为,无论你从任何手段出发,想创建一个线程的话,最终肯定都是构造 Thread 类。(包括以上几种方式,甚至通过反射,最终不也是 newInstance 么)。

那么,如果按照这个逻辑的话,我就可以说,不管创建任何的对象(Object),都是只有一种方式,即构造这个对象(Object) 类。这个结论似乎有些太过无聊了,因为这是一句非常正确的废话。

以 ArrayList 为例,我问你创建 ArrayList 有几种方式。你八成会为了炫耀自己知道的多,跟我说,

  1. 通过构造方法,List list = new ArrayList();
  2. 通过 Arrays.asList("a", "b");
  3. 通过Java8提供的Stream API,如 List list = Stream.of("a", "b").collect(Collectors.toList());
  4. 通过guava第三方jar包,List list3 = Lists.newArrayList("a", "b");

等等,仅以上就列举了四种。现在,我告诉你创建 ArrayList 就只有一种方式,即构造一个 ArrayList 类,你抓狂不。

这就如同,我问你从北京出发到上海去有几种方式。

你说可以坐汽车、火车、坐动车、坐高铁,坐飞机。

那不对啊,动车和高铁都属于火车啊,汽车和火车都属于车,车和飞机都属于交通工具。这样就是只有一种方式了,即坐交通工具。

这也不对啊,我不坐交通工具也行啊,我走路过去不行么(我插眼传送也可以啊,就你皮~)。

最后结论就是,只有一种方式,那就是你人到上海即可。这这这,这算什么结论。。。

所以个人认为,说创建线程只有一种方式有些欠妥。

好好的一个技术文,差一点被我写成议论文了。。。

这个仁者见仁智者见智吧。

最后,我们看一下我从网上看到的一个非常有意思的题目。

有趣的题目

问:一个类实现了 Runnable 接口就会执行默认的 run 方法,然后判断 target 不为空,最后执行在 Runnable接口中实现的 run 方法。而继承 Thread 类,就会执行重写后的 run 方法。那么,现在我既继承 Thread 类,又实现 Runnable 接口,如下程序,应该输出什么结果呢?

public class TestThread {
    public static void main(String[] args) {
        new Thread(()-> System.out.println("runnable")){
            @Override
            public void run() {
                System.out.println("Thread run");
            }
        }.start();
    }
}

可能乍一看很懵逼,这是什么操作。

其实,我们拆解一下以上代码就会知道,这是一个继承了 Thread 父类的子类对象,重写了父类的 run 方法。然后,父对象 Thread 中,在构造方法中传入了一个 Runnable 接口的实现类,实现了 run 方法。

现在执行了 start 方法,必然会先在子类中寻找 run 方法,找到了就会直接执行,不会执行父类的 run 方法了,因此结果为:Thread run 。

若假设子类没有实现 run 方法,那么就会去父类中寻找 run 方法,而父类的 run 方法会判断是否有 Runnable传过来(即判断target是否为空),现在 target 不为空,因此就会执行 target.run 方法,即打印结果: runnable。

所以,上边的代码看起来复杂,实则很简单。透过现象看本质,我们就会发现,它不过就是考察类的父子继承关系,子类重写了父类的方法就会优先执行子类重写的方法。

和线程结合起来,如果对线程运行机制不熟悉的,很可能就会被迷惑。

查看原文

赞 1 收藏 0 评论 0

烟雨星空 发布了文章 · 2020-09-28

面试官看完我手写的单例直接惊呆了!

前言

单例模式应该算是 23 种设计模式中,最常见最容易考察的知识点了。经常会有面试官让手写单例模式,别到时候傻乎乎的说我不会。

之前,我有介绍过单例模式的几种常见写法。还不知道的,传送门看这里:

设计模式之单例模式

本篇文章将展开一些不太容易想到的问题。带着你思考一下,传统的单例模式有哪些问题,并给出解决方案。让面试官眼中一亮,心道,小伙子有点东西啊!

以下,以 DCL 单例模式为例。

DCL 单例模式

DCL 就是 Double Check Lock 的缩写,即双重检查的同步锁。代码如下,

public class Singleton {

    //注意,此变量需要用volatile修饰以防止指令重排序
    private static volatile Singleton singleton = null;

    private Singleton(){

    }

    public static Singleton getInstance(){
        //进入方法内,先判断实例是否为空,以确定是否需要进入同步代码块
        if(singleton == null){
            synchronized (Singleton.class){
                //进入同步代码块时再次判断实例是否为空
                if(singleton == null){
                    singleton = new Singleton();
                }
            }
        }
        return singleton;
    }
}

乍看,以上的写法没有什么问题,而且我们确实也经常这样写。

但是,问题来了。

DCL 单例一定能确保线程安全吗?

有的小伙伴就会说,你这不是废话么,大家不都这样写么,肯定是线程安全的啊。

确实,在正常情况,我可以保证调用 getInstance 方法两次,拿到的是同一个对象。

但是,我们知道 Java 中有个很强大的功能——反射。对的,没错,就是他。

通过反射,我就可以破坏单例模式,从而调用它的构造函数,来创建不同的对象。

public class TestDCL {
    public static void main(String[] args) throws Exception {
        Singleton singleton1 = Singleton.getInstance();
        System.out.println(singleton1.hashCode()); // 723074861
        Class<Singleton> clazz = Singleton.class;
        Constructor<Singleton> ctr = clazz.getDeclaredConstructor();
        //通过反射拿到无参构造,设为可访问
        ctr.setAccessible(true);
        Singleton singleton2 = ctr.newInstance();
        System.out.println(singleton2.hashCode()); // 895328852
    }
}

我们会发现,通过反射就可以直接调用无参构造函数创建对象。我管你构造器是不是私有的,反射之下没有隐私。

打印出的 hashCode 不同,说明了这是两个不同的对象。

那怎么防止反射破坏单例呢?

很简单,既然你想通过无参构造来创建对象,那我就在构造函数里多判断一次。如果单例对象已经创建好了,我就直接抛出异常,不让你创建就可以了。

修改构造函数如下,

再次运行测试代码,就会抛出异常。

有效的阻止了通过反射去创建对象。

那么,这样写单例就没问题了吗?

这时,机灵的小伙伴肯定就会说,既然问了,那就是有问题(可真是个小机灵鬼)。

但是,是有什么问题呢?

我们知道,对象还可以进行序列化反序列化。那如果我把单例对象序列化,再反序列化之后的对象,还是不是之前的单例对象呢?

实践出真知,我们测试一下就知道了。

// 给 Singleton 添加序列化的标志,表明可以序列化
public class Singleton implements Serializable{ 
    ... //省略不重要代码
}
//测试是否返回同一个对象
public class TestDCL {
    public static void main(String[] args) throws Exception {
        Singleton singleton1 = Singleton.getInstance();
        System.out.println(singleton1.hashCode()); // 723074861
        //通过序列化对象,再反序列化得到新对象
        String filePath = "D:\\singleton.txt";
        saveToFile(singleton1,filePath);
        Singleton singleton2 = getFromFile(filePath);
        System.out.println(singleton2.hashCode()); // 1259475182
    }

    //将对象写入到文件
    private static void saveToFile(Singleton singleton, String fileName){
        try {
            FileOutputStream fos = new FileOutputStream(fileName);
            ObjectOutputStream oos = new ObjectOutputStream(fos);
            oos.writeObject(singleton); //将对象写入oos
            oos.close();
        } catch (IOException e) {
            e.printStackTrace();
        }
    }

    //从文件中读取对象
    private static Singleton getFromFile(String fileName){
        try {
            FileInputStream fis = new FileInputStream(fileName);
            ObjectInputStream ois = new ObjectInputStream(fis);
            return (Singleton) ois.readObject();
        } catch (IOException e) {
            e.printStackTrace();
        } catch (ClassNotFoundException e) {
            e.printStackTrace();
        }
        return null;
    }
}

可以发现,我把单例对象序列化之后,再反序列化之后得到的对象,和之前已经不是同一个对象了。因此,就破坏了单例。

那怎么解决这个问题呢?

我先说解决方案,一会儿解释为什么这样做可以。

很简单,在单例类中添加一个方法 readResolve 就可以了,方法体中让它返回我们创建的单例对象。

然后再次运行测试类会发现,打印出来的 hashCode 码一样。

是不是很神奇。。。

readResolve 为什么可以解决序列化破坏单例的问题?

我们通过查看源码中一些关键的步骤,就可以解决心中的疑惑。

我们思考一下,序列化和反序列化的过程中,哪个流程最有可能有操作空间。

首先,序列化时,就是把对象转为二进制存在 `ObjectOutputStream 流中。这里,貌似好像没有什么特殊的地方。

其次,那就只能看反序列化了。反序列化时,需要从 ObjectInputStream 对象中读取对象,正常读出来的对象是一个新的不同的对象,为什么这次就能读出一个相同的对象呢,我猜这里会不会有什么猫腻?

应该是有可能的。所以,来到我们写的方法 getFromFile中,找到这一行ois.readObject()。它就是从流中读取对象的方法。

点进去,查看 ObjectInputStream.readObject 方法,然后找到 readObject0()方法

再点进去,我们发现有一个 switch 判断,找到 TC_OBJECT 分支。它是用来处理对象类型。

然后看到有一个 readOrdinaryObject方法,点进去。

然后找到这一行,isInstantiable() 方法,用来判断对象是否可实例化。

由于 cons 构造函数不为空,所以这个方法返回 true。因此构造出来一个 非空的 obj 对象 。

再往下走,调用,hasReadResolveMethod 方法去判断变量 readResolveMethod是否为非空。

我们去看一下这个变量,在哪里有没有赋值。会发现有这样一段代码,

点进去这个方法 getInheritableMethod。发现它最后就是为了返回我们添加的readResolve 方法。

同时我们发现,这个方法的修饰符可以是 public , protected 或者 private(我们当前用的就是private)。但是,不允许使用 static 和 abstract 修饰。

再次回到 readOrdinaryObject方法,继续往下走,会发现调用了 invokeReadResolve 方法。此方法,是通过反射调用 readResolve方法,得到了 rep 对象。

然后,判断 rep 是否和 obj 相等 。 obj 是刚才我们通过构造函数创建出来的新对象,而由于我们重写了 readResolve 方法,直接返回了单例对象,因此 rep 就是原来的单例对象,和 obj 不相等。

于是,把 rep 赋值给 obj ,然后返回 obj。

所以,最终得到这个 obj 对象,就是我们原来的单例对象。

至此,我们就明白了是怎么一回事。

一句话总结就是:当从对象流 ObjectInputStream 中读取对象时,会检查对象的类否定义了 readResolve 方法。如果定义了,则调用它返回我们想指定的对象(这里就指定了返回单例对象)。

总结

因此,完整的 DCL 就可以这样写,

public class Singleton implements Serializable {

    //注意,此变量需要用volatile修饰以防止指令重排序
    private static volatile Singleton singleton = null;

    private Singleton(){
        if(singleton != null){
            throw new RuntimeException("Can not do this");
        }
    }

    public static Singleton getInstance(){
        //进入方法内,先判断实例是否为空,以确定是否需要进入同步代码块
        if(singleton == null){
            synchronized (Singleton.class){
                //进入同步代码块时再次判断实例是否为空
                if(singleton == null){
                    singleton = new Singleton();
                }
            }
        }
        return singleton;
    }

    // 定义readResolve方法,防止反序列化返回不同的对象
    private Object readResolve(){
        return singleton;
    }
}

另外,不知道细心的读者有没有发现,在看源码中 switch 分支有一个 case TC_ENUM 分支。这里,是对枚举类型进行的处理。

感兴趣的小伙伴可以去研读一下,最终的效果就是,我们通过枚举去定义单例,就可以防止序列化破坏单例。

微信搜「烟雨星空」,白嫖更多好文~
查看原文

赞 1 收藏 1 评论 0

烟雨星空 发布了文章 · 2020-09-14

故事:坐在我隔壁的小王问我什么是HyperLogLog

1

最近坐我隔壁的小王同志,心情真是糟透了。不但工作不顺心,被老板狠狠的批了一顿,连女朋友也跟别人跑了(Y 的让你天天在我面前秀)。

真是不可谓不惨,我都快要同情他了。

看着他萎靡又迷离的眼神,我实在不忍心,就劝他请假几天出去散散心。

临走前,我交代他,有什么紧急的事,就联系哥。

还有,不要忘了我们之间的暗号哦 ~

2

于是,小王就拖着疲惫的身躯,背着双肩背包和最新款mac,穿着他最心爱的格子衫出发了。

去哪呢,这是一个问题。平时宅在家里习惯了,想来一场说走就走的旅行还是真不容易呀。

就在小王犯难的时候。

耳机里应时地传来一句歌声:”坐上了火车去拉萨,去看那神奇的布达拉。“

额,那就去布达拉宫吧。参观一下号称世界上海拔最高的建筑,同时感受一下西藏妹子人民的热情。

3

带着对未来的憧憬,踏上旅途的小王,坐在高铁上,听着音乐,很快就进入了梦乡。

梦中他做了一个奇怪的梦,梦到自己不知怎地来到了陌生的世界。

而自己却不知身在何处,身边只有阵阵的风沙跟随。

心道,我这是到哪了,不是应该到布达拉宫了吗。

努力的向远处张望,却怎么也看不真切。

终于睁大眼睛看清了。

却发现,迎面走过来一位乘务员小姐姐,轻启红唇,对小王说,先生到站了,还请赶快准备行李下车了。

4

哦,原来是个梦啊。

心道,这该死的福报,给我搞的都快分不清自己是庄周还是蝴蝶了。

赶紧下了车,跟着大部队,走向布达拉宫的方向。

还没进到布达拉宫里边,小王就感受到了这伟大建筑的雄伟壮观。真是不虚此行啊。

不自觉的就加快了脚步,想一探究竟。

走着走着,小王却发现周围的人一个一个都不见了踪影,只留下自己形单影只。

忽然,眼前一白,再睁开眼,发现自己置身于一片山林之中。

就在小王心觉奇怪时,隐约听到远处传来一阵阵的嬉笑声和水流声。

好奇心驱使下,小王循着声音走去。隔着一片草丛,发现了让他血脉喷张的画面。

一群 x 身 x x 的仙女正在水中戏耍,一个个毫无顾忌的互相嬉闹。姣好的身材一览无余。

就在小王看的入神之际,突然听到一声大喊:谁?!

下一刻就发现他面前站着一个身穿广袖流仙裙的仙女。还未来得及反应,就感觉脑袋一沉,昏睡过去了。。。

5

也不知道过了多长时间,小王感觉好像一个世纪都过去了。睁开眼发现自己正躺在一个类似古代闺房的床上。却发现身体无论如何也是动不了一分。

透过屏风,像是听到有几个女孩子在谈话。

“怎么从来没有见过这样的人,他为什么和我们长的不太一样。“

“他到底是干什么的,为什么可以穿越结界,来到我们女儿国。一般人是做不到的。”

正在讨论间,却发现声音截然而止。然后听到整齐划一的声音,“恭迎女王陛下”。

然后,发现进来了一个仙女,拥有着绝世容颜,毫无瑕疵的脸蛋,美的不可方物。

原来这就是女儿国的国王。

女王毕竟是见过世面的人,知道小王就是传说中的男人。

然后把小王的禁锢给解除了。小王瞬间感觉身上沉重的力量消失了,浑身轻松。

6

(场景切换)

本来小王只请假了一周,但是眼看第二周就要过完了,也没再收到过小王的消息。

我也纳闷,这家伙怎么回事,旅游放松一下就好了,竟然把时间都忘了。

这还有一大堆工作,我帮他兜着呢,再不回来我就报警了啊(无奈)。

。。。

某天深夜,当我正在发奋写文章时,手机突然收到一条消息。

天王盖地虎

卧槽,这是小王给我发暗号了?

当时,我们约定只有紧急情况下才发暗号,莫非是小王遇到了什么麻烦?

于是,我赶紧对暗号,希望他不要出什么事才好。

小鸡炖蘑菇

随后,小王给我简单叙述了他这一周多的经历。如果不是星哥我经历丰富,差点都被他搞懵逼了。

下面是小王的自述:《《《

那天,我决定去布达拉宫看宫殿,不料,却走到了女儿国的宫殿。

这不要紧,关键是女儿国现在遭遇了一些事情,环境恶化,已经影响到她们的正常生活了。

为了她们的子孙后代,急需一位心地善良,心灵纯洁之人帮助她们化解危机。

其实要做的事情也很简单,就是让我和女儿国的仙女们一起双修就好了。

在女王陛下的一再恳求下,本着助人为乐的精神,我只能留下来帮她们了。

为了更快更效率的完成任务,我记录了这段时间和哪些仙女进行过双修,并把她们进行了编号。

这个好说,因为数据量目前也不大,我决定用 Redis 的 Set 集合来装填数据就可以。

set = {id1,id2,id3}

随着需要我帮助的人越来越多,我发现仙女们各自的体质也稍有不同。因此,每个人和我双修的次数也不固定。

于是,我只能修改记录方式。

用 zset 来分别记录每个人和我双修的次数,

zset = {id1: count1, id2: count2, id3: count3}

后来,仙女数量实在是太多了,以上记录方式已经行不通了,内存会爆掉的。索性我就不算了,何必给自己添麻烦呢。

但是,突然,有天我正在和一个仙女双修呢。女王陛下来到我旁边,看着我辛苦的样子(也或许是我帅气的侧颜)。发现我满脸汗水,于是用那还残留着女王香气的手帕温柔地帮我擦汗。

我能清晰的感觉到女王在我耳边吐气如兰,一双美眸扑闪扑闪地看着我。那细腻光滑、吹弹可破的脸蛋儿,就像刚剥壳的鸡蛋一样。

就在我内心波澜起伏时,女王问我,哥哥,你能估算一下现在大概有多少个仙女双修过了吗。

这下我慌了,这可怎么办呢,我可没有计算这个东东啊。

星哥,江湖救急啊。

》》》

看到这里,我真是气不打一处来,这特么合着我给你顶包,你在外边逍遥快活呢。这真不是人干的事儿啊。

我:你 Y 的,瘦弱的小身板,能经得起折腾吗?

小王: 哎呀,星哥你就不用担心我这个了。我在这天天吃好喝好的,女王还每天给我喝大补汤,我很 OK 的。你赶紧给我解决方案吧。

听到这,我气的打字的双手都在颤抖。单身狗没有人权啊,真是人比人气死人,和小王比,生活真是一个天上一个地下。

生气归生气,但谁让我是好人呢(滴,好人卡),就好事做到底吧。

7

:那个,你可以用 HyperLogLog 啊,它的键只需要花费 12K 的内存,就可以计算 2^64 个不同元素的基数。这样就大大节省你的内存了。

小王:HyperLogLog 是什么鬼,没听说过啊?还有,你说的基数是什么意思呢?

: HyperLogLog 是用来做基数统计的一种算法。当输入元素的数量越来越大时,它所占用的空间却是固定的。这是和集合的不同点,集合是元素越多,占用空间越大。

基数很好理解,就比如说有一个数据集存储了每个仙女每次双修的编号 {1, 3, 5, 8, 3, 5, 9},那么去除重复元素后的基数集就是 {1, 3, 5, 8, 9},基数就是它的个数,这里就是 5 ,代表有 5 个仙女和你一起双修过了。

因为你关心的是有多少个仙女和你双修过,不关心具体都是谁。

小王:这个听起来好像很牛批的样子,那我怎么使用呢?

:你可以使用 pfadd 命令添加元素,命令格式:pfadd key element [element ...],例如,我添加三个仙女,pfadd fairy_practice id1 id2 id3

当计算基数时,就可以用 pfcount 命令,格式:pfcount key [key ...]。如果 key 为一个,计算的是这个 HyperLogLog 的近似基数。如果 key 为多个,就可以计算它们的近似基数和。

注意,这里的基数计算是一个估算值,并不是一个准确的值。

HyperLogLog 只会根据输入的元素计算基数,而不会存储元素本身。这是和集合的另外一个不同点,集合会存储每个输入的元素。

所以,你用 pfcount fairy_practice 就满足要求了。因为女王不就让你计算一个大概值吗,而且也没有让你说出仙女的具体名字啊。

小王:卧槽,这个真是太神奇了。星哥你可是帮了我大忙了。等我忙完这阵子,回去就给你带女儿国的特产哈。

:我去你大 x 的。女儿国能有什么特产,不都是仙女么,你能给我带来几个仙女吗?

小王:。。。(好像不能)

听到这,我真是要被气死了,真是岂有此理,太敷衍我了。

气的我一下子就把电脑给合上了。

天马行空无厘头,vx搜「星哥聊编程」
查看原文

赞 0 收藏 0 评论 0

烟雨星空 关注了用户 · 2020-09-07

SegmentFault @segmentfault

SegmentFault 社区管理媛 - 思否小姐姐

纯粹的技术社区离不开所有开发者的支持和努力 biubiu

更多技术内容与动态欢迎关注 @SegmentFault 官方微博与微信公众号!

点击添加思否小姐姐个人微信号

关注 84103

烟雨星空 发布了文章 · 2020-09-07

JDK15就要来了,你却还不知道JDK8的新特性!

微信搜「烟雨星空」,白嫖更多好文。

现在 Oracle 官方每隔半年就会出一个 JDK 新版本。按时间来算的话,这个月就要出 JDK15 了。然而,大部分公司还是在使用 JDK7 和 8 。

之前去我朋友家,竟然被嘲笑不会用 JDK8 。 不服气的我,回来之后,当然是重点学习之啊。

话不多说,本文目录如下:

目录:

  • lambda 表达式
  • 接口默认方法和静态方法
  • 函数式接口
  • 方法引用
  • Optional
  • Stream API
  • 日期时间新 API

一、lambda表达式

先看下 lambda 表达式是怎么定义的:

lambda 表达式是一个匿名函数。lambda 表达式允许把一个函数作为参数进行传递。

可能刚看到这两句话时,不知道是什么意思。那么,对比一下 js 中的 setInterval 函数的用法,你就能找到一些感觉了。

//每一秒执行一次匿名函数。(模拟时钟)
setInterval(function() {
    console.log("当前时间为:" + new Date());
}, 1000);

如上,function(){}这段,就是一个匿名函数,并且可以把它作为参数传递给 setInterval 函数。

这是因为,在 js 中,函数是一等公民。

然而,在 Java 中,对象才是一等公民。但是,到了 JDK8 我们也可以通过 lambda 表达式表示同样的效果。

lambda 表达式语法如下:

(参数1,参数2) ->  { 方法体 }

左边指定了 lambda 表达式所需要的所有参数,右边用来描述方法体。-> 即为 lambda 运算符。

想一下,在之前我们通过匿名内部类的方式来启动一个线程,是怎么做的?

public class LambdaTest {
    @Test
    public void test(){
        new Thread(new Runnable() {
            @Override
            public void run() {
                System.out.println("线程运行...");
            }
        }).start();
    }
}

现在,若把它改为用 lambda 表达式,则为,

public class LambdaTest {
    @Test
    public void test(){
        // 一行搞定
        new Thread(()->System.out.println("线程运行...")).start();
    }
}

可以发现,明显用 lambda 表达式,写法更简洁了。

其实,Lambda 表达式就是函数式编程的体现。(什么,你还不知道什么是函数式编程? 那还不赶快百度去。)

注意事项:

  • 参数列表的数据类型会自动推断。也就是说,如果匿名函数有参数列表的话,只需要写参数名即可,不需要写参数的类型。
  • 如果参数列表为空,则左边只需要写小括号即可。
  • 如果参数只有一个,则可以省略小括号,只写参数的名称即可。
  • 如果方法体中只有一条执行语句,则可以省略右边的大括号。若有返回值,则可以把 return 和大括号同时省略。

二、接口默认方法和静态方法

接口默认方法

我们知道,在 Java 的接口中,只能定义方法名,不能实现方法体的,具体的实现需要子类去做。

但是,到了 JDK8 就不一样了。在接口中,也可以通过 default关键字来实现方法体。

那么,就有小伙伴疑惑了。好端端的,为什么要加入这个奇怪的功能呢,它有什么用?

当然是为了提高代码的重用性了。此外,接口的默认方法可以在不影响原来的继承体系的情况下,进行功能的拓展,实现接口的向下兼容。

我滴天,好抽象。那,就用实例来说明一下吧。

假设各种动物的继承体系如下,

public interface Animal {
    //所有动物都需要吃东西,具体吃什么,让子类去实现
    void eat();
}
public class Bird implements Animal {
    @Override
    public void eat() {
        System.out.println("早起的鸟儿有虫吃!");
    }
}
public class Cat implements Animal {
    @Override
    public void eat() {
        System.out.println("小猫爱吃鱼!");
    }
}

现在,需要对 Animal接口拓展功能了。动物不能只会吃东西吧,它也许会奔跑,也许会飞行。那么,我在接口中添加两个方法, run 和 fly 就可以了吧。

这样定义方法虽然是可以的,但是,问题就来了。接口中定义了方法,实现类就要实现它的所有方法。小猫会奔跑,但是不会飞啊。而小鸟会飞,你让它在地上跑不是委屈人家嘛。

所以,这个设计不是太合理。

此时,就可以在接口中定义默认方法。子类不需要实现所有方法,可以按需实现,或者直接使用接口的默认方法。

因此,修改 Animal 接口如下,把 run 和 fly 定义为默认方法,

public interface Animal {
    //所有动物都需要吃东西,具体吃什么,让子类去实现
    void eat();

    default void run(){
        System.out.println("我跑");
    }

    default void fly(){
        System.out.println("我飞");
    }
}

public class Main {
    public static void main(String[] args) {
        Bird bird = new Bird();
        bird.fly();

        Cat cat = new Cat();
        cat.run();
    }
}

在 JDK8 的集合中,就对 Collection 接口进行了拓展,如增加默认方法 stream() 等。既增强了集合的一些功能,而且也能向下兼容,不会对集合现有的继承体系产生影响。

接口静态方法

另外,在接口中也可以定义静态方法。这样,就可以直接通过接口名调用静态方法。(这也很正常,接口本来就不能实例化)

需要注意的是,不能通过实现类的对象去调用接口的静态方法。

public interface MyStaticInterface {
    static void method(){
        System.out.println("这是接口的静态方法");
    }
}

public class MyStaticInterfaceImpl implements MyStaticInterface {

    public static void main(String[] args) {
        //直接通过接口名调用静态方法,不能通过实现类的对象调用
        MyStaticInterface.method();
    }
}

三、函数式接口

如果一个接口中只有一个抽象方法,则称其为函数式接口。可以使用 @FunctionalInterface 注解来检测一个接口是否为函数式接口。

JDK提供了常见的最简单的四种函数式接口:(必须掌握哦)

  • Consumer<T>,消费型接口。接收一个参数,没有返回值。其方法有:void accept(T t);
  • Supplier<T>,供给型接口。没有参数,带返回值。 其方法:T get();
  • Function<T, R>,函数型接口。接收一个参数,返回一个结果。其方法:R apply(T t);
  • Predicate<T>,断言型接口。接收一个参数,返回boolean值。其方法:boolean test(T t);

我这里举例了它们的使用方法,

public class LambdaTest {
    @Test
    public void test2(){
        //打印传入的 msg
        printMsg((s)-> System.out.println(s),"听朋友说「烟雨星空」公众号不仅文章好看,还免费送程序员福利,我心动了");
    }

    public void printMsg(Consumer<String> consumer,String msg){
        //消费型,只有传入参数,没有返回值
        consumer.accept(msg);
    }

    @Test
    public void test3(){
        //返回一个 0~99 的随机数
        Integer content = getContent(() -> new Random().nextInt(100));
        System.out.println(content);
    }

    public Integer getContent(Supplier<Integer> supplier){
        //供给型,传入参数为空,带返回值
        return supplier.get();
    }

    @Test
    public void test4(){
        //传入一个字符串,然后把它都转换成大写字母。
        System.out.println(transfer((str) -> str.toUpperCase(), "My wechat : mistyskys"));
    }

    public String transfer(Function<String,String> func,String str){
        // 函数型,传入一个参数,对其进行处理之后,返回一个结果
        return func.apply(str);
    }

    @Test
    public void test5(){
        //定义一个list,用来做筛选
        ArrayList<String> list = new ArrayList<>();
        list.add("zhangsan");
        list.add("lisi");
        list.add("jerry");
        list.add("tom");
        //筛选出集合中,字符串长度大于 3 的,并加入到结果集。
        List<String> filterResult = filter((str) -> str.length() > 3, list);
        System.out.println(filterResult.toString());
    }

    public List<String> filter(Predicate<String> predicate, List<String> list){
        List<String> result = new ArrayList<>();
        for (String str : list) {
            //断言型,传入一个参数,并返回true或者false。
            //这里的逻辑是,若断言为真,则把当前的字符串加入到结果集中
            if(predicate.test(str)){
                result.add(str);
            }
        }
        return result;
    }
}

还有一些其他函数式接口,都在java.util.function包下,可以自行查看。使用方法都是一样的,不再赘述。

除此之外,JDK 中还有很多函数式接口,例如 Comparator.java。只要类上边看到了 @FunctionalInterface 这个注解,你都可以使用 lambda 表达式来简化写法。

四、方法引用

概念:方法引用是用来直接访问类或者实例的已经存在的方法或者构造方法。

这里强调一下已经存在的含义。因为,lambda表达式本质上就是一个匿名函数。我们知道,函数就是做逻辑处理的:拿一些数据,去做一些操作。

如果,我们发现有其他地方(类或者对象)已经存在了相同的逻辑处理方案,那么就可以引用它的方案,而不必重复写逻辑。这就是方法引用。

其实方法引用就是一个lambda表达式的另外一种更简洁的表达方式。也可以说是语法糖。

只不过,这里要求 lambda 表达式需要符合一定的要求。首先,方法体只有一行代码。其次,方法的实现已经存在。此时,就可以用方法引用替换 lambda 表达式。

方法引用的操作符为双冒号::

下边就以最简单的一个我们非常常见的打印语句为例。

//遍历数组里边的元素,并打印,用lambda表达式
String[] arr = new String[]{"zhangsan","lisi"};
Arrays.asList(arr).forEach((s)-> System.out.println(s));

可以发现,lambda 表达式只有一行代码,且方法体逻辑为打印字符串。而打印字符串的方案,在 System.out 对象中已经存在方法 println() 了。

所以,此处 lambda 表达式可以用方法引用替换。

// 注意:方法引用中的方法名不可带括号。
Arrays.asList(arr).forEach(System.out::println);

方法引用有以下四种形式:

  • 对象 :: 实例方法
  • 类 :: 静态方法
  • 类 :: 实例方法
  • 类 :: new

下边举例说明:

public class ReferTest {
    public static void main(String[] args) {
        //函数式接口的抽象方法的参数列表和返回值类型,必须与方法引用对应的方法参数列表和返回值类型保持一致(情况3除外,比较特殊)。
        //======= 1.对象::实例方法 =========
        // lambda 表达式
        Consumer consumer1 = (s) -> System.out.println(s);
        consumer1.accept("hello world");
        //方法引用。Consumer的accept方法,和System.out的println方法结构一样,
        //都是传入一个参数,无返回值。故可以用方法引用。
        Consumer consumer2 = System.out::println;
        consumer2.accept("hello java");

        //======= 2.类::静态方法 =========
        Integer[] arr = new Integer[]{12,20,15};
        List<Integer> list = Arrays.asList(arr);
        // lambda 表达式
        Comparator<Integer> com1 = (o1, o2) -> Integer.compare(o1, o2);
        Collections.sort(list,com1);
        //方法引用。Comparator的compare方法,和Integer的compare静态方法结构一样,
        //都是传入两个参数,返回一个int值,故可以用方法引用。
        Comparator<Integer> com2 = Integer::compare;
        Collections.sort(list,com2);

        //======= 3.类::实例方法 =========
        // lambda表达式
        Comparator<Integer> com3 = (o1, o2) -> o1.compareTo(o2);
        //方法引用。这种形式比较特殊,(o1, o2) -> o1.compareTo(o2) ,
        //当第一个参数o1为调用对象,且第二个参数o2为需要引用方法的参数时,才可用这种方式。
        Comparator<Integer> com4 = Integer::compareTo;

        //======= 4.类::new =========
        // lambda表达式
        Supplier<String> supplier1 = () -> new String();
        //方法引用。这个就比较简单了,就是类的构造器引用,一般用于创建对象。
        Supplier<String> supplier2 = String::new;
    }
}

题外话:方法引用,有时候不太好理解,让人感觉莫名其妙。所以,如果不熟悉的话,用 lambda 表达式完全没有问题。就是习惯的问题,多写就有感觉了。

五、Optional

Optional 类是一个容器类。在之前我们通常用 null 来表达一个值不存在,现在可以用 Optional 更好的表达值存在或者不存在。

这样的目的,主要就是为了防止出现空指针异常 NullPointerException 。

我们知道,像层级关系比较深的对象,中间的调用过程很容易出现空指针,如下代码。

User user = new User();
//中间过程,user对象或者address对象都有可能为空,从而产生空指针异常
String details = user.getAddress().getDetails();

其中,对象的关系如下,

// 地址信息类
public class Address {
    private String province; //省
    private String city; //市
    private String county; //县
    private String details; //详细地址

    public String getProvince() {
        return province;
    }

    public void setProvince(String province) {
        this.province = province;
    }

    public String getCity() {
        return city;
    }

    public void setCity(String city) {
        this.city = city;
    }

    public String getCounty() {
        return county;
    }

    public void setCounty(String county) {
        this.county = county;
    }

    public String getDetails() {
        return details;
    }

    public void setDetails(String details) {
        this.details = details;
    }
}

//用户类
public class User {
    private String name;
    private Address address;

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    public Address getAddress() {
        return address;
    }

    public void setAddress(Address address) {
        this.address = address;
    }
}

在 Optional 类出现之前,为了防止空指针异常,可以这样做。(每一层都添加判空处理)

private static String getUserAddr(User user){
    if(user != null){
        Address address = user.getAddress();
        if(address != null){
            return address.getDetails();
        }else {
            return "地址信息未填写";
        }
    }else {
        return "地址信息未填写";
    }
}

可以发现,代码冗长,还不利于维护,随着层级关系更深,将会变成灾难(是否依稀记得js的回调地狱)。

那么,有了 Optional 类,我们就可以写出更优雅的代码,并且防止空指针异常。(后边就填坑)

下面,就一起领略一下 Optional 的魅力吧!

创建 Optional 对象

实际上,Optional 是对原值(对象)的一层包装,我们看下 Optional 的源码就知道了。

它把真正需要操作的对象 T 封装成 value 属性。构造器私有化,并提供三种静态的创建 Optional 对象的方法。

public final class Optional<T> {
    //EMPTY 代表一个值为空的 Optional 对象
    private static final Optional<?> EMPTY = new Optional<>();

    //用 value 来代表包装的实际值
    private final T value;

    //值为null的构造函数
    private Optional() {
        this.value = null;
    }

    //要求值不为null的构造函数,否则抛出空指针异常,见requireNonNull方法
    private Optional(T value) {
        this.value = Objects.requireNonNull(value);
    }
    
    /** 此为Objects类的requireNonNull方法
    public static <T> T requireNonNull(T obj) {
        if (obj == null)
            throw new NullPointerException();
        return obj;
    }
    */

    // 1. 创建一个值为空的 Optional 对象
    public static<T> Optional<T> empty() {
        @SuppressWarnings("unchecked")
        Optional<T> t = (Optional<T>) EMPTY;
        return t;
    }
    
    // 2. 创建一个值不为空的 Optional 对象
    public static <T> Optional<T> of(T value) {
        return new Optional<>(value);
    }

    // 3. 创建一个值可为空的 Optional 对象
    // 如果值 value 为空,则同1,若不为空,则同2
    public static <T> Optional<T> ofNullable(T value) {
        return value == null ? empty() : of(value);
    }
}

因此,当我们十分确定传入的user对象不为空时,可以用 Optional.of(user)方法。若不确定,则用 Optional.ofNullable(user),这样在后续的操作中可以避免空指针异常(后续map说明)。

常用方法

1、get方法

public T get() {
    //如果值为null,则抛出异常,否则返回非空值value
    if (value == null) {
        throw new NoSuchElementException("No value present");
    }
    return value;
}

2、isPresent方法

//判断值是否存在,若值不为空,则认为存在
public boolean isPresent() {
    return value != null;
}

看到这,不知道有没有小伙伴和我当初有一样的疑惑。既然有判空方法 isPresent,还有获取对象的 get 方法。那开头的那个坑,是不是就可以改写为如下,

//注意此时user类型为Optional<User>
private static String getUserAddr(Optional<User> user){
    //如果user存在,则取address对象
    if(user.isPresent()){
        Address address = user.get().getAddress();
        //把address包装成Optional对象
        Optional<Address> addressOptional = Optional.ofNullable(address);
        //如果address存在,则取details地址信息
        if(addressOptional.isPresent()){
            return addressOptional.get().getDetails();
        }else {
            return "地址信息未填写";
        }
    }else{
        return "地址信息未填写";
    }
}

这样看起来,好像功能也实现了。但是,我们先不说代码并没有简洁(反而更复杂了),其实是陷入了一个怪圈了。

因为,if(user.isPresent()){}和手动判空处理 if(user!=null){}实质上是没有区别的。这就是受之前一直以来的代码思维限制了。

所以,我们不要手动调用 isPresent 方法 。

不要奇怪,isPresent 方法,其实是为了 Optional 中的其他方法服务的(如map方法),本意并不是为了让我们手动调用。你会在后续多个方法中,见到 isPresent 的身影。

3、ifPresent

//传入一个消费型接口,当值存在时,才消费。
public void ifPresent(Consumer<? super T> consumer) {
    if (value != null)
        consumer.accept(value);
}

与 isPresent 方法不同, ifPresent 方法是我们推荐使用的。

如可以这样判空,

Optional<User> user = Optional.ofNullable(new User());
user.ifPresent(System.out::println);
//不要用下边这种
if (user.isPresent()) {
  System.out.println(user.get());
}

4、orElse 和 orElseGet

public T orElse(T other) {
    return value != null ? value : other;
}

public T orElseGet(Supplier<? extends T> other) {
    return value != null ? value : other.get();
}

这两个方法都是当值不存在时,用于返回一个默认值。如user对象为null时,返回默认值。

@Test
public void test1(){
    User user = null;
    System.out.println("orElse调用");
    User user1 = Optional.ofNullable(user).orElse(createUser());
    System.out.println("orElseGet调用");
    User user2 = Optional.ofNullable(user).orElseGet(() -> createUser());
}

private User createUser() {
    //此处打印,是为了查看orElse和orElseGet的区别
    System.out.println("createUser...");
    return new User();
}
//打印结果
orElse调用
createUser...
orElseGet调用
createUser...

以上是user为null时,两个方法是没有区别的。因为都需要创建user对象作为默认值返回。

但是,当user对象不为null时,我们看下对比结果,

@Test
public void test2(){
    User user = new User();
    System.out.println("orElse调用");
    User user1 = Optional.ofNullable(user).orElse(createUser());
    System.out.println("orElseGet调用");
    User user2 = Optional.ofNullable(user).orElseGet(() -> createUser());
}  
//打印结果
orElse调用
createUser...
orElseGet调用

可以发现,当user对象不为null时,orElse依然会创建User对象,而orElseGet不会创建。

所以,当 orElse() 方法传入的参数需要创建对象或者比较耗时的操作时,建议用 orElseGet()

5、orElseThrow

当值为null,可以返回自定义异常。

User user = null;
Optional.ofNullable(user).orElseThrow(IllegalAccessError::new);

若user对象为null,则抛出非法访问。

这样,可以有针对的对特定异常做一些其他处理。因为,会抛出哪些异常的情况,是我们可控的。

6、map

public<U> Optional<U> map(Function<? super T, ? extends U> mapper) {
    Objects.requireNonNull(mapper);
    //看到没,map内部会先调用isPresent方法来做判空处理。
    //所以我们不要自己去调用isPresent方法
    if (!isPresent())
        return empty();
    else {
        return Optional.ofNullable(mapper.apply(value));
    }
}

map类似 Stream 的 map方法。处理完之后,返回的还是一个 Optional 对象,所以可以做链式调用。

User user = new User();
String name = Optional.of(user).map(User::getName)
        .orElse("佚名");
System.out.println(name);

如上,取出user对象的name值,若name为空,返回一个默认值“佚名”(神奇的名字)。

这里,直接调用map方法,就不需要对user对象进行预先判空了。因为在map方法里边,会调用isPresent方法帮我们处理user为null的情况。

到这里,脑袋转圈快的小伙伴,是不是对开头的坑已经有启发了。

没错,我们可以通过 Optional 的链式调用,通过 map,orElse 等操作改写。如下,

private static String getUserAddr1(Optional<User> user){
    //先获取address对象
    return user.map((u)->u.getAddress())
            //再获取details值,
            .map(e -> e.getDetails())
            //若detail为null,则返回一个默认值
            .orElse("地址信息未填写");
}

中间所有可能出现空指针的情况,Optional都会规避。因为 value!=null这个操作已经被封装了。而且在不同的处理阶段,Optional 会自动帮我们包装不同类型的值。

就像上边的操作,第一个map方法包装了User类型的user对象值,第二个map包装了String类型的details值,orElse 返回最终需要的字符串。

7、flatMap

public<U> Optional<U> flatMap(Function<? super T, Optional<U>> mapper) {
    Objects.requireNonNull(mapper);
    if (!isPresent())
        return empty();
    else {
        return Objects.requireNonNull(mapper.apply(value));
    }
}

乍看这个方法和 map 没什么区别。其实,它们的区别就在于传入的 mapper参数的第二个泛型。

map

flatMap

map第二个泛型为? extends U,flatMap第二个泛型为Optional<U>

所以,map方法在最后,用方法Optional.ofNullable 包装成了 Optional 。但是,flatMap就需要我们自己去包装 Optional 了。

下边就看下怎么操作 flatMap。

@Test
public void test3(){
    User user = new User();
    String name = Optional.of(user).flatMap((u) -> this.getUserName(u))
        .orElse("佚名");
    System.out.println(name);
}

//把用户名包装成Optional<String>,作为 Function 接口的返回值,以适配flatMap
private Optional<String> getUserName(User user){
    return Optional.ofNullable(user.getName());
}

8、filter

public Optional<T> filter(Predicate<? super T> predicate) {
    Objects.requireNonNull(predicate);
    if (!isPresent())
        return this;
    else
        return predicate.test(value) ? this : empty();
}    

见名知意,filter 是用来根据条件过滤的,如果符合条件,就返回当前 Optional 对象本身,否则返回一个值为 null的 Optional 对象。

如下,过滤姓名为空的 user。

User user = new User();
//由于user没有设置 name,所以返回一个值为 null 的 optionalUser
Optional<User> optionalUser = Optional.of(user).filter((u) -> this.getUserName(u).isPresent());
//由于值为 null,所以get方法抛出异常 NoSuchElementException
optionalUser.get();

六、Stream API

首先,什么是 Stream 流?

流 (Stream) 和 Java 中的集合类似。但是集合中保存的数据,而流中保存的是,对集合或者数组中数据的操作。

之所以叫流,是因为它就像一个流水线一样。从原料经过 n 道加工程序之后,变成可用的成品。

如果,你有了解过 Spark 里边的 Streaming,就会有一种特别熟悉的感觉。因为它们的思想和用法如此相似。

包括 lazy 思想,都是在需要计算结果的时候,才真正执行。 类似 Spark Streaming 对 RDD 的操作,分为转换(transformation)和行动(action)。转换只是记录这些操作逻辑,只有行动的时候才会开始计算。

转换介绍:http://spark.apache.org/docs/...

对应的,Stream API 对数据的操作,有中间操作和终止操作,只有在终止操作的时候才会执行计算。

所以,Stream 有如下特点,

  • Stream 自己不保存数据。
  • Stream 不会改变源对象,每次中间操作后都会产生一个新的 Stream。
  • Stream 的操作是延迟的,中间操作只保存操作,不做计算。只有终止操作时才会计算结果。

那么问题来了,既然 Stream 是用来操作数据的。没有数据源,你怎么操作,因此还要有一个数据源。

于是,stream操作数据的三大步骤为:数据源,中间操作,终止操作。

数据源

流的源可以是一个数组,一个集合,一个生成器方法等等。

1、使用 Collection 接口中的 default 方法。

default Stream<E> stream()  //返回一个顺序流
default Stream<E> parallelStream() //返回一个并行流

此处,我们也就明白了,为什么 JDK8 要引入默认方法了吧。

由于 Collection 集合父接口定义了这些默认方法,所以像 List,Set 这些子接口下的实现类都可以用这种方式生成一个 Stream 流。

public class StreamTest {
    public static void main(String[] args) {
        List<String> list = new ArrayList<>();
        list.add("zhangzan");
        list.add("lisi");
        list.add("wangwu");
        //顺序流
        Stream<String> stream = list.stream();
        //并行流
        Stream<String> parallelStream = list.parallelStream();
        //遍历元素
        stream.forEach(System.out::println);
    }
}

2、 Arrays 的静态方法 stream()

 static <T> Stream<T> stream(T[] array)

可以传入各种类型的数组,把它转化为流。如下,传入一个字符串数组。

String[] arr = {"abc","aa","ef"};
Stream<String> stream1 = Arrays.stream(arr);

3、Stream接口的 of() ,generate(),iterate()方法

注意,of() 方法返回的是有限流,即元素个数是有限的,就是你传入的元素个数。

而 generate(),iterate() 这两个方法,是无限流,即元素个数是无限个。

使用方法如下,

//of
Stream<Integer> stream2 = Stream.of(10, 20, 30, 40, 50);
stream.forEach(System.out::println);
//generate,每个元素都是0~99的随机数
Stream<Integer> generate = Stream.generate(() -> new Random().nextInt(100));
//iterate,从0开始迭代,每个元素依次增加2
Stream<Integer> iterate = Stream.iterate(0, x -> x + 2);

4、IntStream,LongStream,DoubleStream 的 of、range、rangeClosed 方法

它们的用法都是一样,不过是直接包装了一层。

实际,of()方法底层用的也是 Arrays.stream()方法。

以 IntStream 类为例,其他类似,

IntStream intStream = IntStream.of(10, 20, 30);
//从0每次递增1,到10,包括0,但不包括10
IntStream rangeStream = IntStream.range(0, 10);
//从0每次递增1,到10,包括0和10
IntStream rangeClosed = IntStream.rangeClosed(0, 10);

中间操作

一个流可以有零个或者多个中间操作,每一个中间操作都会返回一个新的流,供下一个操作使用。

1、筛选与切片

常见的包括:

  • filter
  • limit
  • skip
  • distinct

用法如下:

@Test
public void test1(){
    ArrayList<Employee> list = new ArrayList<>();
    list.add(new Employee("张三",3000));
    list.add(new Employee("李四",5000));
    list.add(new Employee("王五",4000));
    list.add(new Employee("赵六",4500));
    list.add(new Employee("赵六",4500));

    // filter,过滤出工资大于4000的员工
    list.stream()
        .filter((e) -> e.getSalary() > 4000)
        .forEach(System.out::println);

    System.out.println("===============");
    // limit,限定指定个数的元素
    list.stream()
        .limit(3)
        .forEach(System.out::println);

    System.out.println("===============");
    // skip,和 limit 正好相反,跳过前面指定个数的元素
    list.stream()
        .skip(3)
        .forEach(System.out::println);

    System.out.println("===============");
    // distinct,去重元素。注意自定义对象需要重写 equals 和 hashCode方法
    list.stream()
        .distinct()
        .forEach(System.out::println);
}
// 打印结果:
Employee{name='李四', salary=5000}
Employee{name='赵六', salary=4500}
Employee{name='赵六', salary=4500}
===============
Employee{name='张三', salary=3000}
Employee{name='李四', salary=5000}
Employee{name='王五', salary=4000}
===============
Employee{name='赵六', salary=4500}
Employee{name='赵六', salary=4500}
===============
Employee{name='张三', salary=3000}
Employee{name='李四', salary=5000}
Employee{name='王五', salary=4000}
Employee{name='赵六', salary=4500}

2、映射

主要是map,包括:

  • map
  • mapToInt
  • mapToLong
  • mapToDouble
  • flatMap

用法如下:

@Test
public void test2(){
    int[] arr = {10,20,30,40,50};
    // map,映射。每个元素都乘以2
    Arrays.stream(arr)
          .map(e -> e * 2)
          .forEach(System.out::println);

    System.out.println("===============");
    //mapToInt,mapToDouble,mapToLong 用法都一样,不同的是返回类型分别是
    //IntStream,DoubleStream,LongStream.
    Arrays.stream(arr)
          .mapToDouble(e -> e * 2 )
          .forEach(System.out::println);

    System.out.println("===============");
    Arrays.stream(arr)
          .flatMap(e -> IntStream.of(e * 2))
          .forEach(System.out::println);
}
//打印结果:
20
40
60
80
100
===============
20.0
40.0
60.0
80.0
100.0
===============
20
40
60
80
100

这里需要说明一下 map 和 flatMap。上边的例子看不出来它们的区别。因为测试数据比较简单,都是一维的。

其实,flatMap 可以把二维的集合映射成一维的。看起来,就像把二维集合压平似的。( flat 的英文意思就是压平)

现在给出这样的数据,若想返回所有水果单词的所有字母("appleorangebanana"),应该怎么做?

String[] fruits = {"apple","orange","banana"};

先遍历 fruits 数组拿到每个单词;然后,对每个单词切分,切分后还是一个数组 。

注意,此时的数组是一个二维数组,形如 [["a","p","p","l","e"] , [],[]]。

所以需要进一步遍历,再遍历(遍历两次),如下

String[] fruits = {"apple","orange","banana"};
Stream.of(fruits).map((s) -> Stream.of(s.split("")))
                 .forEach(e -> e.forEach(System.out::print));

虽然也实现了需求,但是整个流程太复杂了,单 forEach 遍历就两次。

用 flatMap 可以简化这个过程,如下。其实,就是把中间的二维数组直接压平成一维的单个元素,减少遍历次数。

Stream.of(fruits).map(s -> s.split(""))
                 .flatMap(e -> Stream.of(e))
                 .forEach(System.out::print);

还有一种写法,不用 map,直接 flatMap。

Stream.of(fruits).flatMap(s -> Stream.of(s.split("")))
                  .collect(Collectors.toList())
                  .forEach(System.out::print);

3、排序

  • sorted()
  • sorted(Comparator<? super T> comparator)

排序有两个方法,一个是无参的,默认按照自然顺序。一个是带参的,可以指定比较器。

@Test
public void test4(){
    String[] arr = {"abc","aa","ef"};
    //默认升序(字典升序)
    Stream.of(arr).sorted().forEach(System.out::println);
    System.out.println("=====");
    //自定义排序,字典降序
    Stream.of(arr).sorted((s1,s2) -> s2.compareTo(s1)).forEach(System.out::println);
}    

终止操作

一个流只会有一个终止操作。 Stream只有遇到终止操作,它的源才开始执行遍历操作。注意,在这之后,这个流就不能再使用了。

1、查找与匹配

  • allMatch(Predicate p),传入一个断言型函数,检查是否匹配所有元素
  • anyMatch( (Predicate p) ),检查是否匹配任意一个元素
  • noneMatch(Predicate p),检查是否没有匹配的元素,如果都不匹配,则返回 true
  • findFirst(),返回第一个元素
  • findAny(),返回任意一个元素
  • count(),返回流中的元素总个数
  • max(Comparator c),按给定的规则排序后,返回最大的元素
  • min(Comparator c),按给定的规则排序后,返回最小的元素
  • forEach(Consumer c),迭代遍历元素(内部迭代)

由于上边 API 过于简单,不再做例子。

2、规约

规约就是 reduce ,把数据集合到一起。相信你肯定听说过 hadoop 的 map-reduce ,思想是一样的。

这个方法着重说一下,比较常用,有三个重载方法。

2.1、一个参数

Optional<T> reduce(BinaryOperator<T> accumulator);

传入的是一个二元运算符,返回一个 Optional 对象。

我们需要看下 BinaryOperator 这个函数式接口的结构,不然后边就不懂了,也不知道怎么用。

//BinaryOperator继承自 BiFunction<T,T,T>,我们发现它们的泛型类型都是T,完全相同
public interface BinaryOperator<T> extends BiFunction<T,T,T> {
}

public interface BiFunction<T, U, R> {
    //传入 T 和 U,返回类型 R ,这就说明它们的参数类型可以完全不相同,当然也可以完全相同
    //对应的它的子类 BinaryOperator 就是完全相同的
    R apply(T t, U u);
}

使用方式如下,

Integer[] arr = {1,2,3,4,5,6};
Integer res1 = Stream.of(arr).reduce((x, y) -> x + y).get();
System.out.println(res1);
// 结果:21

它表达的意思是,反复合并计算。如上,就是先计算1和2的和,然后计算结果3再和下一个元素3求和,依次反复计算,直到最后一个元素。

2.2、两个参数

T reduce(T identity, BinaryOperator<T> accumulator);

传入两个参数,第一个参数代表初始值,第二个参数是二元运算符。返回的类型是 T ,而不是 Optional。

如下,给一个 10 的初始值,依次累加,

Integer res2 = Stream.of(arr).reduce(10, (x, y) -> x + y);
System.out.println(res2);
// 结果:31

注意:accumulator 累加器函数需要满足结合律。如上,加法就满足结合律。

它的计算过程示意图可以用下图表示,

identity 先和 T1 做计算,返回值作为中间结果,参与下一次和 T2 计算,如此反复。

另外需要注意的时,源码中说明了一句,并不强制要求一定按顺序计算。

but is not constrained to execute sequentially.

也就是说,实际计算时有可能会和图中表示的计算顺序不太一样。比如 T1 先和 T3 运算,然后结果再和 T2 运算。

这也是为什么它要求函数符合结合律,因为交换元素顺序不能影响到最终的计算结果。

2.3、三个参数

<U> U reduce(U identity,
             BiFunction<U, ? super T, U> accumulator,
             BinaryOperator<U> combiner);

这个参数有三个,比较复杂。我们分析一下。

  • U identity,这个是初始值。(但是,在并行计算中,和两个参数的 reduce 初始值含义不一样,一会儿说)x需要注意,初始值和规约函数的返回值类型一致都是 U。而 Stream 流中的元素类型是 T ,所以可以和 U 相同,也可以不相同。
  • BiFunction<U, ? super T, U> accumulator,这是一个累加器。其类型是BiFunction,需要注意这个输入 U 于 T 类型的两个参数,返回类型是 U 。也就是说,输入的第一个参数和返回值类型一样,输入的第二个参数和 Stream 流中的元素类型一样。
  • BinaryOperator<U> combiner,这是一个组合器。其类型是 BinaryOperator ,前面说过这个函数式接口,它是传入两个相同类型的参数,返回值类型也相同,都是 U 。需要注意的是,这个参数只有在 reduce 并行计算中才会生效。

因此,我们可以把 reduce 分为非并行和并行两种情况。

2.3.1、 非并行规约

非并行情况下,第三个参数不起作用,identity 代表的是初始值。

以下的计算,是初始化一个 list,并向其中添加流中的元素。

Integer[] arr = {1,2,3,4,5,6};
ArrayList<Integer> res = Stream.of(arr).reduce(Lists.newArrayList(0),
                                               (l, e) -> {
                                                   l.add(e);
                                                   return l;
                                               },
                                               (l, c) -> {
                                                   //结果不会打印这句话,说明第三个参数没有起作用
                                                   System.out.println("combiner");
                                                   l.addAll(c);
                                                   return l;
                                               });
System.out.println(res);
// [0, 1, 2, 3, 4, 5, 6]

2.3.2、并行规约

并行规约,用的是 fork-join 框架思想,分而治之。把一个大任务分成若干个子任务,然后再合并。

不了解 fork-join 的,可以看这篇文章介绍:fork-join框架分析

所以,这里的累加器 accumulator 是用来计算每个子任务的。组合器 combiner 是用来把若干个子任务合并计算的。

下边用例子说明:

Integer res4 = Stream.of(1,2,3,4).parallel().reduce(1,
                (s,e) -> s + e,
                (sum, s) -> sum + s);
System.out.println(res4); // 结果:14

奇了怪了,计算结果应该是 10 的,为什么是 14 呢。

这里就要说明,这个 identity 初始值了。它是在每次执行 combiner 的时候,都会把 identity 累加上。

具体执行几次 combiner ,可以通过以下方式计算出来 。( c 并不能代表有几个执行子任务)

AtomicInteger c = new AtomicInteger(0);
Integer res4 = Stream.of(1,2,3,4).parallel().reduce(1,
        (s,e) -> s + e,
        (sum, s) -> {c.getAndIncrement(); return sum + s;});
System.out.println(c); //3
System.out.println(res4); //14

c 为 3 代表执行了 3 次 combiner ,最后计算总结果时,还会再加一次初始值,所以结果为:

(1+2+3+4) + (3+1) * 1 = 14
// 1+2+3+4 为正常非并行结算的和,3+1 为总共计算了几次初始值。

我们可以通过加大stream的数据量来验证猜想。从1 加到 100 。初始值为 2 。

AtomicInteger count = new AtomicInteger(0);
int length = 100;
Integer[] arr1 = new Integer[length];
for (int i = 0; i < length; i++) {
    arr1[i] = i + 1;
}
Integer res5 = Stream.of(arr1).parallel().reduce(2,
                         (s,e) -> s + e,
                         (sum, s) -> {count.getAndIncrement(); return sum + s;});
System.out.println(count.get()); //15
System.out.println(res5); //5082 

即:

(1+...+100) + (15+1) * 2 = 5082

怎么正常使用?

那么,问题就来了。这个并行计算不靠谱啊,都把计算结果计算错了。

这是为什么呢,是它的算法有问题么?

非也,其实是我们的用法姿势错了。可以看下源码中对 identity 的说明。

This means that for all u, combiner(identity, u) is equal to u.

意思是,需要每次 combiner 运算时,identity 的值保证 u == combiner(identity,u) 是一个恒等式。

那么,为了满足这个要求,此种情况只能让 identity = 0 。

故,改写程序如下,

//其他都不变,只有 identity 由 2 改为 0
AtomicInteger count = new AtomicInteger(0);
int length = 100;
Integer[] arr1 = new Integer[length];
for (int i = 0; i < length; i++) {
    arr1[i] = i + 1;
}
Integer res5 = Stream.of(arr1).parallel().reduce(0,
                         (s,e) -> s + e,
                         (sum, s) -> {count.getAndIncrement(); return sum + s;});
System.out.println(count.get()); //15
System.out.println(res5); //5050 

当然,只要保证 identity 不影响这个恒等式就行。

比如,对于 set 集合会自动去重,这种情况下,也可以使用并行计算,

//初始化一个set,然后把stream流的元素添加到set中,
//需要注意:用并行的方式,这个set集合必须是线程安全的。否则会报错ConcurrentModificationException
Set<Integer> res3 = Stream.of(1, 2, 3, 4).parallel().reduce(Collections.synchronizedSet(Sets.newHashSet(10),
                (l, e) -> {
                    l.add(e);
                    return l;
                },
                (l, c) -> {
                    l.addAll(c);
                    return l;
                });
System.out.println(res3);

3、收集

收集操作,可以把流收集到 List,Set,Map等中。而且,Collectors 类中提供了很多静态方法,方便的创建收集器供我们使用。

这里举几个常用的即可。具体的 API 可以去看 Collectors 源码(基本涵盖了各种,最大值,最小值,计数,分组等功能。)。

 @Test
public void test6() {
    ArrayList<Employee> list = new ArrayList<>();
    list.add(new Employee("张三", 3000));
    list.add(new Employee("李四", 5000));
    list.add(new Employee("王五", 4000));
    list.add(new Employee("赵六", 4500));

    //把所有员工的姓名收集到list中
    list.stream()
        .map(Employee::getName)
        .collect(Collectors.toList())
        .forEach(System.out::println);

    //求出所有员工的薪资平均值
    Double average = list.stream()
        .collect(Collectors.averagingDouble(Employee::getSalary));
    System.out.println(average);

}

七、日期时间新 API

JDK8 之前的时间 API 存在线程安全问题,并且设计混乱。因此,在 JDK8 就重新设计了一套 API。

如下,线程不安全的例子。

@Test
public void test1() throws Exception{
    SimpleDateFormat sdf = new SimpleDateFormat("yyyyMMdd");
    ExecutorService executorService = Executors.newFixedThreadPool(10);
    List<Future<Date>> list = new ArrayList<>();
    for (int i = 0; i < 10; i++) {
        Future<Date> future = executorService.submit(() -> sdf.parse("20200905"));
        list.add(future);
    }
    for (Future<Date> future : list) {
        System.out.println(future.get());
    }

}

多次运行,就会报错 java.lang.NumberFormatException 。

接下来,我们就学习下新的时间 API ,然后改写上边的程序。

LocalDate,LocalTime,LocalDateTime

它们都是不可变类,用法差不多。以 LocalDate 为例。

1、创建时间对象

  • now ,静态方法,根据当前时间创建对象
  • of,静态方法,根据指定日期、时间创建对象
  • parse,静态方法,通过字符串指定日期
LocalDate localDate1 = LocalDate.now();
System.out.println(localDate1);  //2020-09-05
LocalDate localDate2 = LocalDate.of(2020, 9, 5);
System.out.println(localDate2); //2020-09-05
LocalDate localDate3 = LocalDate.parse("2020-09-05");
System.out.println(localDate3); //2020-09-05

2、获取年月日周

  • getYear,获取年
  • getMonth ,获取月份,返回的是月份的枚举值
  • getMonthValue,获取月份的数字(1-12)
  • getDayOfYear,获取一年中的第几天(1-366)
  • getDayOfMonth,获取一个月中的第几天(1-31)
  • getDayOfWeek,获取一周的第几天,返回的是枚举值
LocalDate currentDate = LocalDate.now();
System.out.println(currentDate.getYear()); //2020
System.out.println(currentDate.getMonth()); // SEPTEMBER
System.out.println(currentDate.getMonthValue()); //9
System.out.println(currentDate.getDayOfYear()); //249
System.out.println(currentDate.getDayOfMonth()); //5
System.out.println(currentDate.getDayOfWeek()); // SATURDAY

3、日期比较,前后或者相等

  • isBefore ,第一个日期是否在第二个日期之前
  • isAfter,是否在之后
  • equals,日期是否相同
  • isLeapYear,是否是闰年

它们都返回的是布尔值。

LocalDate date1 = LocalDate.of(2020, 9, 5);
LocalDate date2 = LocalDate.of(2020, 9, 6);
System.out.println(date1.isBefore(date2)); //true
System.out.println(date1.isAfter(date2)); //false
System.out.println(date1.equals(date2)); //false
System.out.println(date1.isLeapYear()); //true

4、日期加减

  • plusDays, 加几天
  • plusWeeks, 加几周
  • plusMonths, 加几个月
  • plusYears,加几年

减法同理,

LocalDate nowDate = LocalDate.now();
System.out.println(nowDate);  //2020-09-05
System.out.println(nowDate.plusDays(1)); //2020-09-06
System.out.println(nowDate.plusWeeks(1)); //2020-09-12
System.out.println(nowDate.plusMonths(1)); //2020-10-05
System.out.println(nowDate.plusYears(1)); //2021-09-05

时间戳 Instant

Instant 代表的是到从 UTC 时区 1970年1月1日0时0分0秒开始计算的时间戳。

Instant now = Instant.now();
System.out.println(now.toString()); // 2020-09-05T14:11:07.074Z
System.out.println(now.toEpochMilli()); // 毫秒数, 1599315067074 

时间段 Duration

用于表示时间段 ,可以表示 LocalDateTime 和 Instant 之间的时间段,用 between 创建。

LocalDateTime today = LocalDateTime.now(); //今天的日期时间
LocalDateTime tomorrow = today.plusDays(1); //明天
Duration duration = Duration.between(today, tomorrow); //第二个参数减去第一个参数的时间差
System.out.println(duration.toDays()); //总天数,1
System.out.println(duration.toHours()); //小时,24
System.out.println(duration.toMinutes()); //分钟,1440
System.out.println(duration.getSeconds()); //秒,86400
System.out.println(duration.toMillis()); //毫秒,86400000
System.out.println(duration.toNanos()); // 纳秒,86400000000000

日期段 Period

和时间段 Duration,但是 Period 只能精确到年月日。

有两种方式创建 Duration 。

LocalDate today = LocalDate.now(); //今天
LocalDate date = LocalDate.of(2020,10,1); //国庆节
//1. 用 between 创建 Period 对象
Period period = Period.between(today, date);
System.out.println(period); // P26D
//2. 用 of 创建 Period 对象
Period of = Period.of(2020, 9, 6);
System.out.println(of); // P2020Y9M6D
// 距离国庆节还有 0 年 0 月 26 天 
System.out.printf("距离国庆节还有 %d 年 %d 月 %d 天" , period.getYears(),period.getMonths(),period.getDays());

时区 ZoneId

ZoneId 表示不同的时区。

  • getAvailableZoneIds() ,获取所有时区信息,大概40多个时区
  • of(id),根据时区id获得对应的 ZoneId 对象
  • systemDefault,获取当前时区
Set<String> availableZoneIds = ZoneId.getAvailableZoneIds();
availableZoneIds.forEach(System.out::println); //打印所有时区
ZoneId of = ZoneId.of("Asia/Shanghai");   //获取亚洲上海的时区对象
System.out.println(of);  
System.out.println(ZoneId.systemDefault()); //当前时区为: Asia/Shanghai

日期时间格式化

JDK1.8 提供了线程安全的日期格式化类 DateTimeFormatter。

DateTimeFormatter dtf = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
// 1. 日期时间转化为字符串。有两种方式
String format = dtf.format(LocalDateTime.now());
System.out.println(format); // 2020-09-05 23:02:02
String format1 = LocalDateTime.now().format(dtf); //实际上调用的也是 DateTimeFormatter 类的format方法
System.out.println(format1); // 2020-09-05 23:02:02

// 2. 字符串转化为日期。有两种方式,需要注意,月和日位数要补全两位
//第一种方式用的是,DateTimeFormatter.ISO_LOCAL_DATE_TIME ,格式如下
LocalDateTime parse = LocalDateTime.parse("2020-09-05T00:00:00");
System.out.println(parse); // 2020-09-05T00:00
//第二种方式可以自定义格式
LocalDateTime parse1 = LocalDateTime.parse("2020-09-05 00:00:00", dtf);
System.out.println(parse1); // 2020-09-05T00:00

改为线程安全类

接下来,就可以把上边线程不安全的类改写为新的时间 API 。

@Test
public void test8() throws Exception{
    // SimpleDateFormat 改为 DateTimeFormatter
    DateTimeFormatter dtf = DateTimeFormatter.ofPattern("yyyyMMdd");
    ExecutorService executorService = Executors.newFixedThreadPool(10);
    // Date 改为 LocalDate
    List<Future<LocalDate>> list = new ArrayList<>();
    for (int i = 0; i < 10; i++) {
        //日期解析改为 LocalDate.parse("20200905",dtf)
        Future<LocalDate> future = executorService.submit(() -> LocalDate.parse("20200905",dtf));
        list.add(future);
    }
    for (Future<LocalDate> future : list) {
        System.out.println(future.get());
    }

}

PS:如果本文对你有用,就请关注我,给我点赞吧。你的支持是我写作最大的动力 ~

首发于:JDK8新特性最全总结

查看原文

赞 8 收藏 6 评论 0

烟雨星空 发布了文章 · 2020-08-31

不要再问我 in,exists 走不走索引了

微信搜『烟雨星空』,获取最新好文。

前言

最近,有一个业务需求,给我一份数据 A ,把它在数据库 B 中存在,而又比 A 多出的部分算出来。由于数据比较杂乱,我这里简化模型。

然后就会发现,我去,这不就是 not in ,not exists 嘛。

那么问题来了,in, not in , exists , not exists 它们有什么区别,效率如何?

曾经从网上听说,in 和 exists 不会走索引,那么事实真的是这样吗?

带着疑问,我们研究下去。

注意: 在说这个问题时,不说明 MySQL 版本的都是耍流氓,我这里用的是 5.7.18 。

用法讲解

为了方便,我们创建两张表 t1 和 t2 。并分别加入一些数据。(id为主键,name为普通索引)

-- t1
DROP TABLE IF EXISTS `t1`;
CREATE TABLE `t1` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `name` varchar(255) DEFAULT NULL,
  `address` varchar(255) DEFAULT NULL,
  PRIMARY KEY (`id`),
  KEY `idx_t1_name` (`name`(191)) USING BTREE
) ENGINE=InnoDB AUTO_INCREMENT=1009 DEFAULT CHARSET=utf8mb4;

INSERT INTO `t1` VALUES ('1001', '张三', '北京'), ('1002', '李四', '天津'), ('1003', '王五', '北京'), ('1004', '赵六', '河北'), ('1005', '杰克', '河南'), ('1006', '汤姆', '河南'), ('1007', '贝尔', '上海'), ('1008', '孙琪', '北京');

-- t2
DROP TABLE IF EXISTS `t2`;
CREATE TABLE `t2`  (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `name` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL,
  `address` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL,
  PRIMARY KEY (`id`) USING BTREE,
  INDEX `idx_t2_name`(`name`(191)) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 1014 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_general_ci ROW_FORMAT = Dynamic;

INSERT INTO `t2` VALUES (1001, '张三', '北京');
INSERT INTO `t2` VALUES (1004, '赵六', '河北');
INSERT INTO `t2` VALUES (1005, '杰克', '河南');
INSERT INTO `t2` VALUES (1007, '贝尔', '上海');
INSERT INTO `t2` VALUES (1008, '孙琪', '北京');
INSERT INTO `t2` VALUES (1009, '曹操', '魏国');
INSERT INTO `t2` VALUES (1010, '刘备', '蜀国');
INSERT INTO `t2` VALUES (1011, '孙权', '吴国');
INSERT INTO `t2` VALUES (1012, '诸葛亮', '蜀国');
INSERT INTO `t2` VALUES (1013, '典韦', '魏国');

那么,对于当前的问题,就很简单了,用 not in 或者 not exists 都可以把 t1 表中比 t2 表多出的那部分数据给挑出来。(当然,t2 比 t1 多出来的那部分不算)

这里假设用 name 来匹配数据。

select * from t1 where name not in (select name from t2);
或者用
select * from t1 where not exists (select name from t2 where t1.name=t2.name);

得到的结果都是一样的。

但是,需要注意的是,not in 和 not exists 还是有不同点的。

在使用 not in 的时候,需要保证子查询的匹配字段是非空的。如,此表 t2 中的 name 需要有非空限制。如若不然,就会导致 not in 返回的整个结果集为空。

例如,我在 t2 表中加入一条 name 为空的数据。

INSERT INTO `t2` VALUES (1014, NULL, '魏国');

则此时,not in 结果就会返回空。

另外需要明白的是, exists 返回的结果是一个 boolean 值 true 或者 false ,而不是某个结果集。因为它不关心返回的具体数据是什么,只是外层查询需要拿这个布尔值做判断。

区别是,用 exists 时,若子查询查到了数据,则返回真。 用 not exists 时,若子查询没有查到数据,则返回真。

由于 exists 子查询不关心具体返回的数据是什么。因此,以上的语句完全可以修改为如下,

-- 子查询中 name 可以修改为其他任意的字段,如此处改为 1 。
select * from t1 where not exists (select 1 from t2 where t1.name=t2.name);

从执行效率来说,1 > column > * 。因此推荐用 select 1。(准确的说应该是常量值)

in, exists 执行流程

1、 对于 in 查询来说,会先执行子查询,如上边的 t2 表,然后把查询得到的结果和外表 t1 做笛卡尔积,再通过条件进行筛选(这里的条件就是指 name 是否相等),把每个符合条件的数据都加入到结果集中。

sql 如下,

select * from t1 where name in (select name from t2);

伪代码如下:

for(x in A){
    for(y in B){
        if(condition is true) {result.add();}
    }
}

这里的 condition 其实就是对比两张表中的 name 是否相同。

2、对于 exists 来说,是先查询遍历外表 t1 ,然后每次遍历时,再检查在内表是否符合匹配条件,即检查是否存在 name 相等的数据。

sql 如下,

select * from t1 where name exists (select 1 from t2);

伪代码如下:

for(x in A){
    if(exists condition is true){result.add();}
}

对应于此例,就是从 id 为 1001 开始遍历 t1 表 ,然后遍历时检查 t2 中是否有相等的 name 。

如 id=1001时,张三存在于 t2 表中,则返回 true,把 t1 中张三的这条记录加入到结果集,继续下次循环。 id=1002 时,李四不在 t2 表中,则返回 false,不做任何操作,继续下次循环。直到遍历完整个 t1 表。

是否走索引?

针对网上说的 in 和 exists 不走索引,那么究竟是否如此呢?

我们在 MySQL 5.7.18 中验证一下。(注意版本号哦)

单表查询

首先,验证单表的最简单的情况。我们就以 t1 表为例,id为主键, name 为普通索引。

分别执行以下语句,

explain select * from t1 where id in (1001,1002,1003,1004);
explain select * from t1 where id in (1001,1002,1003,1004,1005);
explain select * from t1 where name in ('张三','李四');
explain select * from t1 where name in ('张三','李四','王五');

为什么我要分别查不同的 id 个数呢? 看截图,

会惊奇的发现,当 id 是四个值时,还走主键索引。而当 id 是五个值时,就不走索引了。这就很耐人寻味了。

再看 name 的情况,

同样的当值多了之后,就不走索引了。

所以,我猜测这个跟匹配字段的长度有关。按照汉字是三个字节来计算,且程序设计中喜欢用2的n次幂的尿性,这里大概就是以 16 个字节为分界点。

然而,我又以同样的数据,去我的服务器上查询(版本号 5.7.22),发现四个id值时,就不走索引了。因此,估算这里的临界值为 12 个字节。

不管怎样,这说明了,在 MySQL 中应该对 in 查询的字节长度是有限制的。(没有官方确切说法,所以,仅供参考)

多表涉及子查询

我们主要是去看当前的这个例子中的两表查询时, in 和 exists 是否走索引。

一、分别执行以下语句,主键索引(id)和普通索引(name),在 in , not in 下是否走索引。

explain select * from t1 where id in (select id from t2); --1
explain select * from t1 where name in (select name from t2); --2
explain select * from t1 where id not in (select id from t2); --3
explain select * from t1 where name not in (select name from t2); --4

结果截图如下,

1、t1 走索引,t2 走索引。

1

2、t1 不走索引,t2不走索引。(此种情况,实测若把name改为唯一索引,则t1也会走索引)

2

3、t1 不走索引,t2走索引。

3

4、t1不走索引,t2不走索引。

4

我滴天,这结果看起来乱七八糟的,好像走不走索引,完全看心情。

但是,我们发现只有第一种情况,即用主键索引字段匹配,且用 in 的情况下,两张表才都走索引。

这个到底是不是规律呢?有待考察,且往下看。

二、接下来测试,主键索引和普通索引在 exists 和 not exists 下的情况。sql如下,

explain select * from t1 where exists (select 1 from t2 where t1.id=t2.id);
explain select * from t1 where exists (select 1 from t2 where t1.name=t2.name);
explain select * from t1 where not exists (select 1 from t2 where t1.id=t2.id);
explain select * from t1 where not exists (select 1 from t2 where t1.name=t2.name);

这个结果就非常有规律了,且看,

有没有发现, t1 表哪种情况都不会走索引,而 t2 表是有索引的情况下就会走索引。为什么会出现这种情况?

其实,上一小节说到了 exists 的执行流程,就已经说明问题了。

它是以外层表为驱动表,无论如何都会循环遍历的,所以会全表扫描。而内层表通过走索引,可以快速判断当前记录是否匹配。

效率如何?

针对网上说的 exists 一定比 in 的执行效率高,我们做一个测试。

分别在 t1,t2 中插入 100W,200W 条数据。

我这里,用的是自定义函数来循环插入,语句参考如下,(没有把表名抽离成变量,因为我没有找到方法,尴尬)

-- 传入需要插入数据的id开始值和数据量大小,函数返回结果为最终插入的条数,此值正常应该等于数据量大小。
-- id自增,循环往 t1 表添加数据。这里为了方便,id、name取同一个变量,address就为北京。
delimiter // 
drop function if exists insert_datas1//
create function insert_datas1(in_start int(11),in_len int(11)) returns int(11)
begin  
  declare cur_len int(11) default 0;
  declare cur_id int(11);
  set cur_id = in_start;
    
  while cur_len < in_len do
     insert into t1 values(cur_id,cur_id,'北京');
  set cur_len = cur_len + 1;
  set cur_id = cur_id + 1;
  end while; 
  return cur_len;
end  
//
delimiter ;
-- 同样的,往 t2 表插入数据
delimiter // 
drop function if exists insert_datas2//
create function insert_datas2(in_start int(11),in_len int(11)) returns int(11)
begin  
  declare cur_len int(11) default 0;
  declare cur_id int(11);
  set cur_id = in_start;
    
  while cur_len < in_len do
     insert into t2 values(cur_id,cur_id,'北京');
  set cur_len = cur_len + 1;
  set cur_id = cur_id + 1;
  end while; 
  return cur_len;
end  
//
delimiter ;

在此之前,先清空表里的数据,然后执行函数,

select insert_datas1(1,1000000);

对 t2 做同样的处理,不过为了两张表数据有交叉,就从 70W 开始,然后插入 200W 数据。

select insert_datas2(700000,2000000);

在家里的电脑,实际执行时间,分别为 36s 和 74s。

不知为何,家里的电脑还没有在 Docker 虚拟机中跑的脚本快。。害,就这样凑合着用吧。

等我有了新欢钱,就把它换掉,哼哼。

同样的,把上边的执行计划都执行一遍,进行对比。我这里就不贴图了。

in 和 exists 孰快孰慢

为了方便,主要拿以下这两个 sql 来对比分析。

select * from t1 where id in (select id from t2);
select * from t1 where exists (select 1 from t2 where t1.id=t2.id);

执行结果显示,两个 sql 分别执行 1.3s 和 3.4s 。

注意此时,t1 表数据量为 100W, t2 表数据量为 200W 。

按照网上对 in 和 exists 区别的通俗说法,

如果查询的两个表大小相当,那么用in和exists差别不大;如果两个表中一个较小一个较大,则子查询表大的用exists,子查询表小的用in;

对应于此处就是:

  • 当 t1 为小表, t2 为大表时,应该用 exists ,这样效率高。
  • 当 t1 为大表,t2 为小表时,应该用 in,这样效率较高。

而我用实际数据测试,就把第一种说法给推翻了。因为很明显,t1 是小表,但是 in 比 exists 的执行速度还快。

为了继续测验它这个观点,我把两个表的内表外表关系调换一下,让 t2 大表作为外表,来对比查询,

select * from t2 where id in (select id from t1);
select * from t2 where exists (select 1 from t1 where t1.id=t2.id);

执行结果显示,两个 sql 分别执行 1.8s 和 10.0s 。

是不是很有意思。 可以发现,

  • 对于 in 来说,大表小表调换了内外层关系,执行时间并无太大区别。一个是 1.3s,一个是 1.8s。
  • 对于 exists 来说,大小表调换了内外层关系,执行时间天壤之别,一个是 3.4s ,一个是 10.0s,足足慢了两倍。

一、以查询优化器维度对比。

为了探究这个结果的原因。我去查看它们分别在查询优化器中优化后的 sql 。

select * from t1 where id in (select id from t2); 为例,顺序执行以下两个语句。

-- 此为 5.7 写法,如果是 5.6版本,需要用 explain extended ...
explain select * from t1 where id in (select id from t2);
-- 本意为显示警告信息。但是和 explain 一块儿使用,就会显示出优化后的sql。需要注意使用顺序。
show warnings;

在结果 Message 里边就会显示我们要的语句。

-- message 优化后的sql
select `test`.`t1`.`id` AS `id`,`test`.`t1`.`name` AS `name`,`test`.`t1`.`address` AS `address` from `test`.`t2` join `test`.`t1` where (`test`.`t2`.`id` = `test`.`t1`.`id`)

可以发现,这里它把 in 转换为了 join 来执行。

这里没有用 on,而用了 where,是因为当只有 join 时,后边的 on 可以用 where 来代替。即 join on 等价于 join where 。

PS: 这里我们也可以发现,select 最终会被转化为具体的字段,知道为什么我们不建议用 select 了吧。

同样的,以 t2 大表为外表的查询情况,也查看优化后的语句。

explain select * from t2 where id in (select id from t1);
show warnings;

我们会发现,它也会转化为 join 的。

select `test`.`t2`.`id` AS `id`,`test`.`t2`.`name` AS `name`,`test`.`t2`.`address` AS `address` from `test`.`t1` join `test`.`t2` where (`test`.`t2`.`id` = `test`.`t1`.`id`)

这里不再贴 exists 的转化 sql ,其实它没有什么大的变化。

二、以执行计划维度对比。

我们再以执行计划维度来对比他们的区别。

explain select * from t1 where id in (select id from t2);
explain select * from t2 where id in (select id from t1);
explain select * from t1 where exists (select 1 from t2 where t1.id=t2.id);
explain select * from t2 where exists (select 1 from t1 where t1.id=t2.id);

执行结果分别为,

1

2

3

4

可以发现,对于 in 来说,大表 t2 做外表还是内表,都会走索引的,小表 t1 做内表时也会走索引。看它们的 rows 一列也可以看出来,前两张图结果一样。

对于 exists 来说,当小表 t1 做外表时,t1 全表扫描,rows 近 100W;当 大表 t2 做外表时, t2 全表扫描,rows 近 200W 。这也是为什么 t2 做外表时,执行效率非常低的原因。

因为对于 exists 来说,外表总会执行全表扫描的,当然表数据越少越好了。

最终结论: 外层大表内层小表,用in。外层小表内层大表,in和exists效率差不多(甚至 in 比 exists 还快,而并不是网上说的 exists 比 in 效率高)。

not in 和 not exists 孰快孰慢

此外,实测对比 not in 和 not exists 。

explain select * from t1 where id not in (select id from t2);
explain select * from t1 where not exists (select 1 from t2 where t1.id=t2.id);
explain select * from t1 where name not in (select name from t2);
explain select * from t1 where not exists (select 1 from t2 where t1.name=t2.name);

explain select * from t2 where id not in (select id from t1);
explain select * from t2 where not exists (select 1 from t1 where t1.id=t2.id);
explain select * from t2 where name not in (select name from t1);
explain select * from t2 where not exists (select 1 from t1 where t1.name=t2.name);

小表做外表的情况下。对于主键来说, not exists 比 not in 快。对于普通索引来说, not in 和 not exists 差不了多少,甚至 not in 会稍快。

大表做外表的情况下,对于主键来说, not in 比 not exists 快。对于普通索引来说, not in 和 not exists 差不了多少,甚至 not in 会稍快。

感兴趣的同学,可自行尝试。以上边的两个维度(查询优化器和执行计划)分别来对比一下。

join 的嵌套循环 (Nested-Loop Join)

为了理解为什么这里的 in 会转换为 join ,我感觉有必要了解一下 join 的三种嵌套循环连接。

1、简单嵌套循环连接,Simple Nested-Loop Join ,简称 SNLJ

join 即是 inner join ,内连接,它是一个笛卡尔积,即利用双层循环遍历两张表。

我们知道,一般在 sql 中都会以小表作为驱动表。所以,对于 A,B 两张表,若A的结果集较少,则把它放在外层循环,作为驱动表。自然,B 就在内层循环,作为被驱动表。

简单嵌套循环,就是最简单的一种情况,没有做任何优化。

因此,复杂度也是最高的,O(mn)。伪代码如下,

for(id1 in A){
    for(id2 in B){
        if(id1==id2){
            result.add();
        }
    }
}

2、索引嵌套循环连接,Index Nested-Loop Join ,简称 INLJ

看名字也能看出来了,这是通过索引进行匹配的。外层表直接和内层表的索引进行匹配,这样就不需要遍历整个内层表了。利用索引,减少了外层表和内层表的匹配次数。

所以,此种情况要求内层表的列要有索引。

伪代码如下,

for(id1 in A){
    if(id1 matched B.id){
        result.add();
    }
}

3、块索引嵌套连接,Block Nested-Loop Join ,简称 BNLJ

块索引嵌套连接,是通过缓存外层表的数据到 join buffer 中,然后 buffer 中的数据批量和内层表数据进行匹配,从而减少内层循环的次数。

以外层循环100次为例,正常情况下需要在内层循环读取外层数据100次。如果以每10条数据存入缓存buffer中,并传递给内层循环,则内层循环只需要读取10次(100/10)就可以了。这样就降低了内层循环的读取次数。

MySQL 官方文档也有相关说明,可以参考:https://dev.mysql.com/doc/refman/5.7/en/nested-loop-joins.html#block-nested-loop-join-algorithm

所以,这里转化为 join,可以用到索引嵌套循环连接,从而提高了执行效率。

声明: 以上是以我的测试数据为准,测出来的结果。实际真实数据和测试结果很有可能会不太一样。如果有不同意见,欢迎留言讨论。

查看原文

赞 28 收藏 23 评论 9

烟雨星空 发布了文章 · 2020-08-03

同事问我MySQL怎么递归查询,我懵逼了

前言

最近在做的业务场景涉及到了数据库的递归查询。我们公司用的 Oracle ,众所周知,Oracle 自带有递归查询的功能,所以实现起来特别简单。

但是,我记得 MySQL 是没有递归查询功能的,那 MySQL 中应该怎么实现呢?

于是,就有了这篇文章。

文章主要知识点:

  • Oracle 递归查询, start with connect by prior 用法
  • find_in_set 函数
  • concat,concat_ws,group_concat 函数
  • MySQL 自定义函数
  • 手动实现 MySQL 递归查询

Oracle 递归查询

在 Oracle 中是通过 start with connect by prior 语法来实现递归查询的。

按照 prior 关键字在子节点端还是父节点端,以及是否包含当前查询的节点,共分为四种情况。

prior 在子节点端(向下递归)

第一种情况: start with 子节点id = ' 查询节点 ' connect by prior 子节点id = 父节点id

select * from dept start with id='1001' connet by prior id=pid;

这里,按照条件 id='1001' 对当前节点以及它的子节点递归查询。查询结果包含自己及所有子节点。

第二种情况: start with 父节点id= ' 查询节点 ' connect by prior 子节点id = 父节点 id

select * from dept start with pid='1001' connect by prior id=pid;

这里,按照条件 pid='1001' 对当前节点的所有子节点递归查询。查询结果只包含它的所有子节点,不包含自己

其实想一想也对,因为开始条件是以父节点为根节点,且向下递归,自然不包含当前节点。

prior 在父节点端(向上递归)

第三种情况: start with 子节点id= ' 查询节点 ' connect by prior 父节点id = 子节点id

select * from dept start with id='1001' connect by prior pid=id;

这里按照条件 id='1001' ,对当前节点及其父节点递归查询。查询结果包括自己及其所有父节点。

第四种情况: start with 父节点id= ' 查询节点 ' connect by prior 父节点id = 子节点id

select * from dept start with pid='1001' connect by prior pid=id;

这里按照条件 pid='1001',对当前节点的第一代子节点以及它的父节点递归查询。查询结果包括自己的第一代子节点以及所有父节点。(包括自己

其实这种情况也好理解,因为查询开始条件是以 父节点为根节点,且向上递归,自然需要把当前父节点的第一层子节点包括在内。

以上四种情况初看可能会让人迷惑,容易记混乱,其实不然。

我们只需要记住 prior 的位置在子节点端,就向下递归,在父节点端就向上递归。

  • 开始条件若是子节点的话,自然包括它本身的节点。
  • 开始条件若是父节点的话,则向下递归时,自然不包括当前节点。而向上递归,需要包括当前节点及其第一代子节点。

MySQL 递归查询

可以看到,Oracle 实现递归查询非常的方便。但是,在 MySQL 中并没有帮我们处理,因此需要我们自己手动实现递归查询。

为了方便,我们创建一个部门表,并插入几条可以形成递归关系的数据。

DROP TABLE IF EXISTS `dept`;
CREATE TABLE `dept`  (
  `id` varchar(10) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL,
  `name` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL,
  `pid` varchar(10) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL,
  PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_general_ci ROW_FORMAT = Dynamic;

INSERT INTO `dept`(`id`, `name`, `pid`) VALUES ('1000', '总公司', NULL);
INSERT INTO `dept`(`id`, `name`, `pid`) VALUES ('1001', '北京分公司', '1000');
INSERT INTO `dept`(`id`, `name`, `pid`) VALUES ('1002', '上海分公司', '1000');
INSERT INTO `dept`(`id`, `name`, `pid`) VALUES ('1003', '北京研发部', '1001');
INSERT INTO `dept`(`id`, `name`, `pid`) VALUES ('1004', '北京财务部', '1001');
INSERT INTO `dept`(`id`, `name`, `pid`) VALUES ('1005', '北京市场部', '1001');
INSERT INTO `dept`(`id`, `name`, `pid`) VALUES ('1006', '北京研发一部', '1003');
INSERT INTO `dept`(`id`, `name`, `pid`) VALUES ('1007', '北京研发二部', '1003');
INSERT INTO `dept`(`id`, `name`, `pid`) VALUES ('1008', '北京研发一部一小组', '1006');
INSERT INTO `dept`(`id`, `name`, `pid`) VALUES ('1009', '北京研发一部二小组', '1006');
INSERT INTO `dept`(`id`, `name`, `pid`) VALUES ('1010', '北京研发二部一小组', '1007');
INSERT INTO `dept`(`id`, `name`, `pid`) VALUES ('1011', '北京研发二部二小组', '1007');
INSERT INTO `dept`(`id`, `name`, `pid`) VALUES ('1012', '北京市场一部', '1005');
INSERT INTO `dept`(`id`, `name`, `pid`) VALUES ('1013', '上海研发部', '1002');
INSERT INTO `dept`(`id`, `name`, `pid`) VALUES ('1014', '上海研发一部', '1013');
INSERT INTO `dept`(`id`, `name`, `pid`) VALUES ('1015', '上海研发二部', '1013');

没错,刚才 Oracle 递归,就是用的这张表。

图1

另外,在这之前,我们需要复习一下几个 MYSQL中的函数,后续会用到。

find_in_set 函数

函数语法:find_in_set(str,strlist)

str 代表要查询的字符串 , strlist 是一个以逗号分隔的字符串,如 ('a,b,c')。

此函数用于查找 str 字符串在字符串 strlist 中的位置,返回结果为 1 ~ n 。若没有找到,则返回0。

举个栗子:

select FIND_IN_SET('b','a,b,c,d'); 

结果返回 2 。因为 b 所在位置为第二个子串位置。

此外,在对表数据进行查询时,它还有一种用法,如下:

select * from dept where FIND_IN_SET(id,'1000,1001,1002'); 

结果返回所有 id 在 strlist 中的记录,即 id = '1000' ,id = '1001' ,id = '1002' 三条记录。

看到这,对于我们要解决的递归查询,不知道你有什么启发没。

以向下递归查询所有子节点为例。我想,是不是可以找到一个包含当前节点和所有子节点的以逗号拼接的字符串 strlist,传进 find_in_set 函数。就可以查询出所有需要的递归数据了。

那么,现在问题就转化为怎样构造这样的一个字符串 strlist 。

这就需要用到以下字符串拼接函数了。

concat,concat_ws,group_concat 函数

一、字符串拼接函数中,最基本的就是 concat 了。它用于连接N个字符串,如,

select CONCAT('M','Y','S','Q','L') from dual; 

结果为 'MYSQL' 字符串。

二、concat 是以逗号为默认的分隔符,而 concat_ws 则可以指定分隔符,第一个参数传入分隔符,如以下划线分隔。

三、group_concat 函数更强大,可以分组的同时,把字段以特定分隔符拼接成字符串。

用法:group_concat( [distinct] 要连接的字段 [order by 排序字段 asc/desc ] [separator '分隔符'] )

可以看到有可选参数,可以对将要拼接的字段值去重,也可以排序,指定分隔符。若没有指定,默认以逗号分隔。

对于 dept 表,我们可以把表中的所有 id 以逗号拼接。(这里没有用到 group by 分组字段,则可以认为只有一组)

MySQL 自定义函数,实现递归查询

可以发现以上已经把字符串拼接的问题也解决了。那么,问题就变成怎样构造有递归关系的字符串了。

我们可以自定义一个函数,通过传入根节点id,找到它的所有子节点。

以向下递归为例。 (讲解自定义函数写法的同时,讲解递归逻辑)

delimiter $$ 
drop function if exists get_child_list$$ 
create function get_child_list(in_id varchar(10)) returns varchar(1000) 
begin 
    declare ids varchar(1000) default ''; 
    declare tempids varchar(1000); 
 
    set tempids = in_id; 
    while tempids is not null do 
        set ids = CONCAT_WS(',',ids,tempids); 
        select GROUP_CONCAT(id) into tempids from dept where FIND_IN_SET(pid,tempids)>0;  
    end while; 
    return ids; 
end  
$$ 
delimiter ; 

(1) delimiter &dollar;&dollar; ,用于定义结束符。我们知道 MySQL 默认的结束符为分号,表明指令结束并执行。但是在函数体中,有时我们希望遇到分号不结束,因此需要暂时把结束符改为一个随意的其他值。我这里设置为 &dollar;&dollar;,意思是遇到 &dollar;&dollar; 才结束,并执行当前语句。

(2)drop function if exists get_child_list&dollar;&dollar; 。若函数 get_child_list 已经存在了,则先删除它。注意这里需要用 当前自定义的结束符 &dollar;&dollar; 来结束并执行语句。 因为,这里需要和下边的函数体单独区分开来。

(3)create function get_child_list 创建函数。并且参数传入一个根节点的子节点id,需要注意一定要注明参数的类型和长度,如这里是 varchar(10)。returns varchar(1000) 用来定义返回值参数类型。

(4)begin 和 end 中间包围的就是函数体。用来写具体的逻辑。

(5)declare 用来声明变量,并且可以用 default 设置默认值。

这里定义的 ids 即作为整个函数的返回值,是用来拼接成最终我们需要的以逗号分隔的递归串的。

而 tempids 是为了记录下边 while 循环中临时生成的所有子节点以逗号拼接成的字符串。

(6) set 用来给变量赋值。此处把传进来的根节点赋值给 tempids 。

(7) while do ... end while; 循环语句,循环逻辑包含在内。注意,end while 末尾需要加上分号。

循环体内,先用 CONCAT_WS 函数把最终结果 ids 和 临时生成的 tempids 用逗号拼接起来。

然后以 FIND_IN_SET(pid,tempids)>0 为条件,遍历在 tempids 中的所有 pid ,寻找以此为父节点的所有子节点 id ,并且通过 GROUP_CONCAT(id) into tempids 把这些子节点 id 都用逗号拼接起来,并覆盖更新 tempids 。

等下次循环进来时,就会再次拼接 ids ,并再次查找所有子节点的所有子节点。循环往复,一层一层的向下递归遍历子节点。直到判断 tempids 为空,说明所有子节点都已经遍历完了,就结束整个循环。

这里,用 '1000' 来举例,即是:(参看图1的表数据关系)

第一次循环:
  tempids=1000    ids=1000    tempids=1001,1002 (1000的所有子节点)
第二次循环:
  tempids=1001,1002     ids=1000,1001,1002     tempids=1003,1004,1005,1013 (1001和1002的所有子节点)
第三次循环:
  tempids=1003,1004,1005,1013 
  ids=1000,1001,1002,1003,1004,1005,1013 
  tempids=1003和1004和1005及1013的所有子节点
...
最后一次循环,因找不到子节点,tempids=null,就结束循环。

(8)return ids; 用于把 ids 作为函数返回值返回。

(9)函数体结束以后,记得用结束符 &dollar;&dollar; 来结束整个逻辑,并执行。

(10)最后别忘了,把结束符重新设置为默认的结束符分号 。

自定义函数做好之后,我们就可以用它来递归查询我们需要的数据了。如,我查询北京研发部的所有子节点。

以上是向下递归查询所有子节点的,并且包括了当前节点,也可以修改逻辑为不包含当前节点,我就不演示了。

手动实现递归查询(向上递归)

相对于向下递归来说,向上递归比较简单。

因为向下递归时,每一层递归一个父节点都对应多个子节点。

而向上递归时,每一层递归一个子节点只对应一个父节点,关系比较单一。

同样的,我们可以定义一个函数 get_parent_list 来获取根节点的所有父节点。

delimiter $$ 
drop function if exists get_parent_list$$ 
create function get_parent_list(in_id varchar(10)) returns varchar(1000) 
begin 
    declare ids varchar(1000); 
    declare tempid varchar(10); 
     
    set tempid = in_id; 
    while tempid is not null do 
        set ids = CONCAT_WS(',',ids,tempid); 
        select pid into tempid from dept where id=tempid; 
    end while; 
    return ids; 
end 
$$ 
delimiter ; 
 

查找北京研发二部一小组,以及它的递归父节点,如下:

注意事项

我们用到了 group_concat 函数来拼接字符串。但是,需要注意它是有长度限制的,默认为 1024 字节。可以通过 show variables like "group_concat_max_len"; 来查看。

注意,单位是字节,不是字符。在 MySQL 中,单个字母占1个字节,而我们平时用的 utf-8下,一个汉字占3个字节。

这个对于递归查询还是非常致命的。因为一般递归的话,关系层级都比较深,很有可能超过最大长度。(尽管一般拼接的都是数字字符串,即单字节)

所以,我们有两种方法解决这个问题:

  1. 修改 MySQL 配置文件 my.cnf ,增加 group_concat_max_len = 102400 #你要的最大长度
  2. 执行以下任意一个语句。SET GLOBAL group_concat_max_len=102400; 或者 SET SESSION group_concat_max_len=102400;

    他们的区别在于,global是全局的,任意打开一个新的会话都会生效,但是注意,已经打开的当前会话并不会生效。而 session 是只会在当前会话生效,其他会话不生效。

    共同点是,它们都会在 MySQL 重启之后失效,以配置文件中的配置为准。所以,建议直接修改配置文件。102400 的长度一般也够用了。假设一个id的长度为10个字节,也能拼上一万个id了。

除此之外,使用 group_concat 函数还有一个限制,就是不能同时使用 limit 。如,

本来只想查5条数据来拼接,现在不生效了。

不过,如果需要的话,可以通过子查询来实现,

查看原文

赞 1 收藏 1 评论 0

烟雨星空 发布了文章 · 2020-07-17

终于,病毒向我伸出了魔爪......

前言

服务器好端端的竟然中了挖矿病毒!!!

可怜我那 1 核 2 G 的服务器,又弱又小,却还免除不了被拉去当矿工的命运,实在是惨啊惨。

事情原来是这样的。。。

就在今天下午,我准备登陆自己的远程服务器搞点东西的时候,突然发现 ssh 登陆不上了。

如上,提示被拒绝。这个问题很明显就是服务器没有我的公钥,或者不识别我的公钥,然后拒绝登录。

这就很难办了,我确定我的公钥是一直没有变动过的,不应该会出现这种情况啊。

还有让我头疼的是,我当初为了安全起见,设置过此台服务器只能通过 ssh 的方式免密登录。而且禁止了密码直接登录,这样也防止了别人通过破解我的密码而登录服务器。

当前,只有我这个 mac 还有家里的 win 两台电脑有 ssh 权限。(其实,当时我也想到了这种情况,就怕万一有一天某台电脑登录不上,另外一台还能做备选。嘿嘿,我是不是很机智!)

那么,目前的解决办法,就是要么等着下班回家,用另外一个电脑操作,把当前这个电脑的公钥加到服务器的authorized_keys 文件里。要么,就只能把服务器重装了。

但是,好奇心驱使我去探究一下,到底是什么原因导致了服务器连接不上,而不是直接重装服务器。那样的话,就太没意思了。

通过 VNC 方式登录服务器

因为我用的是腾讯云服务器嘛,于是,就登录到了腾讯云的控制台,想看一下是否还有其它“走后门”的方式,让我绕过 ssh 或者不受密码登录的限制。

没想到,还真的有方法。如下图,可以通过 VNC 的方式进去,然后输入账号密码就可以直接登录,不受限制。

可以看到已经进入服务器了。上一次登录时间是昨天下午,这个时间点没错。

发现问题

当然,正常来讲,我应该先去 authorized_keys 文件检查一下我的公钥是否有问题。但是,习惯性的操作让我 top 了一下,却发现了另外一个问题。

等等,这是什么鬼! 有一个 sysupdate 进程占用了 CPU 51.2%,另外还有一个进程 networkservice 占用了 47.8% 。这两个加起来,就已经占用了 99% 了。

实际上,在腾讯云后台也能监控到服务器的实时状况。

很明显,这两个进程是比较异常的。而且,之前也没有见过这种名字。于是,习惯性的,我就在网上搜了一下 sysupdate。直接,就出来了一堆结果,挖矿病毒。

我去,听这名字,难不成就是传说中的比特币挖矿?不管那么多了,先解决当前的问题吧。

解决问题

1、确认病毒位置

先通过 systemctl status {进程号} 查看一下它的状态信息,以及有没有相关联的进程。以 sysupdate 进程号 16142为例。

可以发现它是从昨天晚上九点开始运行起来的。怪不得,昨天下午下班前还能用,今天就不能用了。

还可以通过 ls -l proc/{进程号}/exe 命令查看它具体的位置。最后发现都在 /etc 目录下。

如上图,这五个都是“挖矿病毒所用到的文件”。哼哼,从颜色上就能看出来他们是一伙的。

然而,我并没有着急把它们清除掉,却突然脑子一抽,想研究一下它们的脚本。因为我看到有一个 update.sh ,里边肯定写了一些病毒执行相关的命令。

我把他们全部都复制到了我自己的目录下 /root/test/。然后打开了 update.sh 脚本,看里边写了些什么。

我估计,能看着服务器都被病毒攻击了,还有闲情研究人家是怎么制作病毒的,我是第一个吧。。

虽然菜鸡我对 linux 不熟,但是大概可以看出来一些东西,如SELINUX 系统被关闭了,我的 authorized_keys 文件也被改动了,竟然无耻的还把 wget、curl 等命令改了名字。

下边,还可以看到病毒脚本的网络路径。难不成是从这个地址下载下来的?

2、删除定时任务

看一下有没有定时任务,因为有可能它会跑一个定时任务,定时的执行脚本,生成病毒文件和进程等。

可以进入 /var/spool/cron/ 目录查看定时任务。也可以通过 crontab -l查看。

没想到却都没有发现。

如果有的话 ,删除 /var/spoool/cron/目录下的所有文件。或者执行crontab -r命令,清空任务列表。

3、杀掉进程,删除病毒文件

kill -9 {进程号} 把上边的两个进程都杀掉,然后删除 /etc 目录下的那五个文件。

注意删除文件时,直接用普通的 rm -rf 不能行。因为病毒文件被锁定了,需要通过 chattr -i {文件名} 解锁之后,再删除。

4、删除 authorized_keys 文件

这个文件里记录了可以通过 ssh 免密登录的所有终端的公钥。路径在 ~/.ssh/authorized_keys 。通过 vi 命令打开。

可以看到文件里已经被改动了,多了两个未知的公钥,这肯定就是攻击者的公钥。前面的三个都是我自己的公钥。

可以直接删除此文件,等稍后再修复为自己的公钥。

5、恢复 wget 和 curl 命令

从 update.sh 文件中可以看到这两个命令名称被改了,对于习惯了这样使用的人来说肯定不爽,那就改回来就好了。

如下为可选的的命令。我这里就需要前两行就行了,因为 which cur 之后发现,只存在 /bin下,/usr/bin/不存在

mv /bin/wge /bin/wget
mv /bin/cur /bin/curl
mv /usr/bin/wge /usr/bin/wget
mv /usr/bin/cur /usr/bin/curl

6、修复 SELINUX

SELinux 是 linux 的一个安全子系统。可以通过命令 getenforce 查看服务状态。

其实从 update.sh 文件中也可以看到此服务被关闭了。

修改 /etc/selinux/config 文件,将 SELINUX=disabled 修改为 SELINUX=enforcing。

修改完成后,需要重启服务器才能生效。

找到原因

其实,以上步骤搞完,还差一步。

你总不能被攻击的不明不白吧,为什么别人会攻击到你的服务器呢。

后来,从网上找到了一篇介绍,说:

挖矿病毒,利用Redis的未授权访问漏洞进行攻击。
Redis 默认配置为6379端口无密码访问,如果redis以root用户启动,攻击者可以通过公网直接链接redis,向root账户写入SSH公钥文件,以此获取服务器权限注入病毒

我去,看完之后,感觉这个描述简直不能太准了。

因为,昨天下午,我就是因为要测试通过 redis 的 zset 来实现延时队列的一个功能。用本地代码连接了服务器的 redis 。当时就在防火墙中把 6379 端口打开了。

谁曾想,一晚上的功夫,就被人家攻击了。

我想,挖矿人肯定也是找大量的机器来实验,看能否通过这些漏洞(肯定不限于只有 redis),操纵对方的服务器。于是,我就幸运的成为了那个倒霉蛋。

最后,我粗暴的把 redis 服务关了,并且去掉了 6379 的端口。

额,其实有更温柔的方案可选,比如更改 redis 的默认端口号,或者给 redis 添加密码。

最后

感觉整篇下来,好像除了知道 redis 的这个漏洞外,就没有其他收获了。主要是,我的安全意识还是比较薄弱吧。

毕竟,服务器只是拿来玩玩用的。最后实在不行也可以重装系统,完事又是一条好汉。

公司的服务器肯定不会这样的,都有专门的运维人员来做这些安全工作。如果是线上服务器被人家拉去挖矿,好歹能拿我这篇文章吹牛逼了。。。

查看原文

赞 1 收藏 1 评论 0

烟雨星空 发布了文章 · 2020-06-23

面试官:换人!他连哈希扣的都不懂

前言

相信你面试的时候,肯定被问过 hashCode 和 equals 相关的问题 。如:

  • hashCode 是什么?它是怎么得来的?有什么用?
  • 经典题,equals 和 == 有什么区别?
  • 为什么要重写 equals 和 hashCode ?
  • 重写了 equals ,就必须要重写 hashCode 吗?为什么?
  • hashCode 相等时,equals 一定相等吗?反过来呢?

好的,上面就是灵魂拷问环节。其实,这些问题仔细想一下也不难,主要是平时我们很少去思考它。

正文

下面就按照上边的问题顺序,一个一个剖析它。扒开 hashCode 的神秘面纱。

什么是 hashCode?

我们通常说的 hashCode 其实就是一个经过哈希运算之后的整型值。而这个哈希运算的算法,在 Object 类中就是通过一个本地方法 hashCode() 来实现的(HashMap 中还会有一些其它的运算)。

public native int hashCode();

可以看到它是一个本地方法。那么,想要了解这个方法到底是用来干嘛的,最直接有效的方法就是,去看它的源码注释。

下边我就用我蹩脚的英文翻译一下它的意思。。。

返回当前对象的一个哈希值。这个方法用于支持一些哈希表,例如 HashMap 。

通常来讲,它有如下一些约定:

  • 若对象的信息没有被修改,那么,在一个程序的执行期间,对于相同的对象,不管调用多少次 hashCode 方法,都应该返回相同的值。当然,在相同程序的不同执行期间,不需要保持结果一致。
  • 若两个对象的 equals 方法返回值相同,那么,调用它们各自的 hashCode 方法时,也必须返回相同的结果。(ps: 这句话解答了上边的一些问题,后面会用例子来证明这一点)
  • 当两个对象的 equals 方法返回值不同时,那么它们的 hashCode 方法不用保证必须返回不同的值。但是,我们应该知道,在这种情况下,我们最好也设计成 hashCode 返回不同的值。因为,这样做有助于提高哈希表的性能。

在实际情况下,Object 类的 hashCode 方法在不同的对象中确实返回了不同的哈希值。这通常是通过把对象的内部地址转换为一个整数来实现的。

ps: 这里说的内部地址就是指物理地址,也就是内存地址。需要注意的是,虽然 hashCode 值是依据它的内存地址而得来的。但是,不能说 hashCode 就代表对象的内存地址,实际上,hashCode 地址是存放在哈希表中的。

上边的源码注释真可谓是句句珠玑,把 hashCode 方法解释的淋漓尽致。一会儿我通过一个案例说明,就能明白我为什么这样说了。

什么是哈希表?

上文中提到了哈希表。什么是哈希表呢?我们直接看百度百科的解释。

用一张图来表示它们的关系。

左边一列就是一些关键码(key),通过哈希函数,它们都会得到一个固定的值,分别对应右边一列的某个值。右边的这一列就可以认为是一张哈希表。

而且,我们会发现,有可能有些 key 不同,但是它们对应的哈希值却是一样的,例如 aa,bb 都指向 1001 。但是,一定不会出现同一个 key 指向不同的值。

这也非常好理解,因为哈希表就是用来查找 key 的哈希地址的。在 key 确定的情况下,通过哈希函数计算出来的 哈希地址,一定也是确定的。如图中的 cc 已经确定在 1002 位置了,那么就不可能再占据 1003 位置。

思考一下,如果有另外一个元素 ee 来了,它的哈希地址也落在 1002 位置,怎么办呢?

hashCode 有什么用?

其实,上图就已经可以说明一些问题了。我们通过一个 key 计算出它的 hashCode 值,就可以唯一确定它在哈希表中的位置。这样,在查询时,就可以直接定位到当前元素,提高查询效率。

现在我们假设有这样一个场景。我们需要在内存中的一块儿区域存放 10000 个不同的元素(以aa,bb,cc,dd 等为例)。那怎么实现不同的元素插入,相同的元素覆盖呢?

我们最容易想到的方法就是,每当存一个新元素时,就遍历一遍已经存在的元素,看有没有相同的。这样虽然也是可以实现的,但是,如果已经存在了 9000 个元素,你就需要去遍历一下这 9000 个元素。很明显,这样的效率是非常低下的。

我们转换一种思路,还是以上图为例。若来了一个新元素 ff,首先去计算它的 hashCode 值,得出为 1003 。发现此处还没有元素,则直接把这个新元素 ff 放到此位置。

然后,ee 来了,通过计算哈希值得到 1002 。此时,发现 1002 位置已经存在一个元素了。那么,通过 equals 方法比较它们是否相等,发现只有一个 dd 元素,很明显和 ee 不相等。那么,就把 ee 元素放到 dd 元素的后边(可以用链表形式存放)。

我们会发现,当有新元素来的时候,先去计算它们的哈希值,再去确定存放的位置,这样就可以减少比较的次数。如 ff 不需要比较, ee 只需要和 dd 比较一次。

当元素越来越多的时候,新元素也只需要和当前哈希值相同的位置上,已经存在的元素进行比较。而不需要和其他哈希值不同的位置上的元素进行比较。这样就大大减少了元素的比较次数。

图中为了方便,画的哈希表比较小。现在假设,这个哈希表非常的大,例如有这么非常多个位置,从 1001 ~ 9999。那么,新元素插入的时候,有很大概率会插入到一个还没有元素存在的位置上,这样就不需要比较了,效率非常高。但是,我们会发现这样也有一个弊端,就是哈希表所占的内存空间就会变大。因此,这是一个权衡的过程。

有心的同学可能已经发现了。我去,上边的这个做法好熟悉啊。没错,它就是大名鼎鼎的 HashMap 底层实现的思想。对 HashMap 还不了解的,赶紧看这篇文章理一下思路。HashMap 底层实现原理及源码分析

所以,hashCode 有什么用。很明显,提高了查询,插入元素的效率呀。

equals 和 == 有什么区别?

这是万年不变,经久不衰的经典面试题了。让我油然想起,当初为了面试,背诵过的面经了,简直是一把心酸一把泪。现在还能记得这道题的标准答案:equals 比较的是内容, == 比较的是地址。

当时,真的就只是背答案,知其然而不知其所以然。再往下问,为什么要重写 equals ,就懵逼了。

首先,我们应该知道 equals 是定义在所有类的父类 Object 中的。

 public boolean equals(Object obj) {
     return (this == obj);
 }

可以看到,它的默认实现,就是 == ,这是用来比较内存地址的。所以,如果一个对象的 equals 不重写的话,和 == 的效果是一样的。

我们知道,当创建两个普通对象时,一般情况下,它们所对应的内存地址是不一样的。例如,我定义一个 User 类。

public class User {
    private String name;
    private int age;

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    public int getAge() {
        return age;
    }

    public void setAge(int age) {
        this.age = age;
    }

    public User(String name, int age) {
        this.name = name;
        this.age = age;
    }

    public User() {

    }
}

public class TestHashCode {
    public static void main(String[] args) {
        User user1 = new User("zhangsan", 20);
        User user2 = new User("lisi", 18); 

        System.out.println(user1 == user2);
        System.out.println(user1.equals(user2));
    }
}
// 结果: false    false

很明显,zhangsan 和 lisi 是两个人,两个不同的对象。因此,它们所对应的内存地址不同,而且内容也不相等。

注意,这里我还没有对 User 重写 equals,实际此时 equals 使用的是父类 Object 的方法,返回的肯定是不相等的。因此,为了更好地说明问题,我仅把第二行代码修改如下:

//User user2 = new User("lisi", 18);
User user2 = new User("zhangsan", 20);

让 user1 和 user2 的内容相同,都是 zhangsan,20岁。按我们的理解,这虽然是两个对象,但是应该是指的同一个人,都是张三。但是,打印结果,如下:

这有悖于我们的认知,明明是同一个人,为什么 equals 返回的却不相等呢。因此,此时我们就需要把 User 类中的 equals 方法重写,以达到我们的目的。在 User 中添加如下代码(使用 idea 自动生成代码):

public class User {
    ... //省略已知代码
        
    @Override
    public boolean equals(Object o) {
        //若两个对象的内存地址相同,则说明指向的是同一个对象,故内容一定相同。
        if (this == o) return true;
        //类都不是同一个,更别谈相等了
        if (o == null || getClass() != o.getClass()) return false;
        User user = (User) o;
        //比较两个对象中的所有属性,即name和age都必须相同,才可认为两个对象相等
        return age == user.age &&
                Objects.equals(name, user.name);
    }
   
}
//打印结果:  false     true

再次执行程序,我们会发现此时 equals 返回 true ,这才是我们想要的。

因此,当我们使用自定义对象时。如果需要让两个对象的内容相同时,equals 返回 true,则需要重写 equals 方法。

为什么要重写 equals 和 hashCode ?

在上边的案例中,其实我们已经说明了为什么要去重写 equals 。因为,在对象内容相同的情况下,我们需要让对象相等。因此,不能用 Object 类的默认实现,只去比较内存地址,这样是不合理的。

那 hashCode 为什么要重写呢? 这就涉及到集合,如 Map 和 Set (底层其实也是 Map)了。

我们以 HashMap JDK1.8的源码来看,如 put 方法。

我们会发现,代码中会多次进行 hash 值的比较,只有当哈希值相等时,才会去比较 equals 方法。当 hashCode 和 equals 都相同时,才会覆盖元素。get 方法也是如此(先比较哈希值,再比较equals),

只有 hashCode 和 equals 都相等时,才认为是同一个元素,找到并返回此元素,否则返回 null。

这也对应 “hashCode 有什么用?”这一小节。 重写 equals 和 hashCode 的目的,就是为了方便哈希表这样的结构快速的查询和插入。如果不重写,则无法比较元素,甚至造成元素位置错乱。

重写了 equals ,就必须要重写 hashCode 吗?

答案是肯定的。首先,在上边的 JDK 源码注释中第第二点,我们就会发现这句说明。其次,我们尝试重写 equals ,而不重写 hashCode 看会发生什么现象。

public class TestHashCode {
    public static void main(String[] args) {
        User user1 = new User("zhangsan", 20);
        User user2 = new User("zhangsan", 20);

        HashMap<User, Integer> map = new HashMap<>();
        map.put(user1,90);
        System.out.println(map.get(user2));
    }
}
// 打印结果: null

对于代码中的 user1 和 user2 两个对象来说,我们认为他是同一个人张三。定义一个 map ,key 存储 User 对象, value 存储他的学习成绩。

当把 user1 对象作为 key ,成绩 90 作为 value 存储到 map 中时,我们肯定希望,用 key 为 user2 来取值时,得到的结果是 90 。但是,结果却大失所望,得到了 null 。

这是因为,我们自定义的 User 类,虽然重写了 equals ,但是没有重写 hashCode 。当 user1 放到 map 中时,计算出来的哈希值和用 user2 去取值时计算的哈希值不相等。因此,equals 方法都没有比较的机会。认为他们是不同的元素。然而,其实,我们应该认为 user1 和 user2 是相同的元素的。

用图来说明就是,user1 和 user2 存放在了 HashMap 中不同的桶里边,导致查询不到目标元素。

因此,当我们用自定义类来作为 HashMap 的 key 时,必须要重写 hashCode 和 equals 。否则,会得到我们不想要的结果。

这也是为什么,我们平时都喜欢用 String 字符串来作为 key 的原因。 因为, String 类默认就帮我们实现了 equals 和 hashCode 方法的重写。如下,

// String.java
public boolean equals(Object anObject) {
    if (this == anObject) {
        return true;
    }
    if (anObject instanceof String) {
        String anotherString = (String)anObject;
        int n = value.length;
        //从前向后依次比较字符串中的每个字符
        if (n == anotherString.value.length) {
            char v1[] = value;
            char v2[] = anotherString.value;
            int i = 0;
            while (n-- != 0) {
                if (v1[i] != v2[i])
                    return false;
                i++;
            }
            return true;
        }
    }
    return false;
}

public int hashCode() {
    int h = hash;
    if (h == 0 && value.length > 0) {
        char val[] = value;
        //把字符串中的每个字符都取出来,参与运算
        for (int i = 0; i < value.length; i++) {
            h = 31 * h + val[i];
        }
        //把计算出来的最终值,存放在hash变量中。
        hash = h;
    }
    return h;
}

重写 equals 时,可以使用 idea 提供的自动代码,也可以自己手动实现。

public class User {
    ... //省略已知代码
        
    @Override
    public int hashCode() {
        return Objects.hash(name, age);
    }
   
}
//此时,map.get(user2) 可以得到 90 的正确值

在重写了 hashCode 后,使用自定义对象作为 key 时,还需要注意一点,不要在使用过程中,改变对象的内容,这样会导致 hashCode 值发生改变,同样得不到正确的结果。如下,

public class TestHashCode {
    public static void main(String[] args) {
        User user = new User("zhangsan", 20);

        HashMap<User, Integer> map = new HashMap<>();
        map.put(user,90);
        System.out.println(map.get(user));
        user.setAge(18); //把对象的年龄修改为18
        System.out.println(map.get(user));
    }
}
// 打印结果:
// 90
// null

会发现,修改后,拿到的值是 null 。这也是,hashCode 源码注释中的第一点说明的,hashCode 值不变的前提是,对象的信息没有被修改。若被修改,则有可能导致 hashCode 值改变。

此时,有没有联想到其他一些问题。比如,为什么 String 类要设计成不可以变的呢?这里用 String 作为 HashMap 的 key 时,可以算作一个原因。你肯定不希望,放进去的时候还好好的,取出来的时候,却找不到元素了吧。

String 类内部会有一个变量(hash)来缓存字符串的 hashCode 值。只有字符串不可变,才可以保证哈希值不变。

hashCode 相等时,equals 一定相等吗?

很显然不是的。在 HashMap 的源码中,我们就能看到,当 hashCode 相等时(产生哈希碰撞),还需要比较它们的 equals ,才可以确定是否是同一个对象。因此,hashCode 相等时, equals 不一定相等 。

反过来,equals 相等的话, hashCode 一定相等吗? 那必须的。equals 都相等了,那说明在 HashMap 中认为它们是同一个元素,所以 hashCode 值必须也要保证相等。

结论:

  • hashCode 相等,equals 不一定相等。
  • hashCode 不等,equals 一定不等。
  • equals 相等, hashCode 一定相等。
  • equals 不等, hashCode 不一定不等。

关于最后这一点,就是 hashCode 源码注释中提到的第三点。当 equals 不等时,不用必须保证它们的 hashCode 也不相等。但是为了提高哈希表的效率,最好设计成不等。

因为,我们既然知道它们不相等了,那么当 hashCode 设计成不等时。只要比较 hashCode 不相等,我们就可以直接返回 null,而不必再去比较 equals 了。这样,就减少了比较的次数,无疑提高了效率。

结尾

以上就是 hashCode 和 equals 相关的一些问题。相信已经可以解答你心中的疑惑了,也可以和面试官侃侃而谈。再也不用担心,面试官说换人了。

查看原文

赞 1 收藏 1 评论 0

认证与成就

  • 获得 103 次点赞
  • 获得 8 枚徽章 获得 0 枚金徽章, 获得 1 枚银徽章, 获得 7 枚铜徽章

擅长技能
编辑

开源项目 & 著作
编辑

(゚∀゚ )
暂时没有

注册于 2017-06-15
个人主页被 2.5k 人浏览