《Redis深度历险》读后

周末花了两个下午,把之前买的一本书看完了。书名叫做《Redis深度历险:核心原理与应用实践》。书很薄,正文只有230页,全彩印刷,定价79,买的时候有打折,50多块。

全书内容

全书分为5篇,分别是基础和应用篇、原理篇、集群篇、拓展篇和源码篇。

基础应用篇

基础和应用篇,讲了下 Redis 的基础功能,以及 Redis 原生支持的一些功能。读的时候,这一篇原本是想略过的,以为就是要写 Redis 的各种命令的使用。 实际上也确实是讲了各种命令,但是还讲了一些我原本并不了解的功能。这一篇的内容,大致可以分为两种,一是内置的命令以及应用,比如 HyperLogLog、Bitmap等;二是一些功能如何使用 Redis 实现,书中讲了分布式锁、延时队列、限流等。

HyperLogLog

书中给 HyperLogLog 举的以一个应用例子是记录一个网页的UV。计算 uv,需要对不同的用户进行去重,一个很直接的想法就是用一个 set 记录所有访问过的用户。这个方法的问题在于,如果用户数过多,占用的空间会非常大。而 HyperLogLog 可以用很少的空间,完成同样的工作,代价则是计数有误差,大概在 0.81%。Redis 的实现里,这个很少的空间是 12KB,与用户的数目无关。

HyperLogLog 是基于概率基数统计算法。其基本思想是,一个二进制串的集合中,所有元素的第一个为 1 的比特的位置 k,与集合的基数 n 之间,存在n = 2^k 的数学关系,当然,是约等于,并且误差比较大。因此实现上,会进行分桶,用调和平均数减小误差。Redis 的实现里,会使用 2^14 = 16384 个桶。

书中对 HyperLogLog 只讲了大概,背后涉及的伯努利实验以及概率都没有讲,可以自己 google,网上还是有不少文章的。

原理篇

原理篇讲了一些 Redis 的一些功能的基本原理吧。说了下 IO 模型、通信协议、管道、事务等。因为我本身对 Redis 不是很了解,所以对我而言,这一篇还是学到了一些东西,对 Redis 的一些功能的实现有了点了解。但我还是要说,这一篇的内容不够深入,这也是整本书的风格,内容不够深入。

集群篇

集群篇讲了主从、Sentinel、Codis 以及 Redis Clster。看完可以对这几点内容都有些了解,大面上可以了解 Codis 和 Redis Cluster 区别以及不同的技术选型。比如,Codis 有 zk 保存槽位信息,通过 proxy 访问实例;Redis Cluster 去中心化,实例间通过 Gossip 交互,客户端可以直接访问实例,速度更好。这些了解,在使用中可能也够了。不过,这一篇没有深入实现细节。

拓展篇

拓展篇讲了 Redis 5.0 的 Stream,又讲了 Info 命令。然后是过期策略、懒惰删除等。甚至还有如何用 Jedis 以及用 Spiped 来安全传输。总之,这一篇讲的比较杂,设计了 Redis 内置的命令,也有 Redis 的原理以及实现考量,又有如何使用 Redis 的库。算是在源码篇之前,又不适合放到其他篇的一些内容吧。

源码篇

源码篇,顾名思义,是讲源码的。这一篇的源码,主要是关于 Redis 的数据结构的,比如字符串、hash、list、zset等等。这一篇,可以很明显的感觉到,Redis 实现过程中,对于内存的斤斤计较,以及单线程下对于 CPU 占用时长的考量。

总结

整本书看下来,收获不算小,对 Redis 也有了更多的了解。书的语言也是比较通俗易懂,图解也是很清楚的。看得比较顺畅。缺点呢,也有一些。前边也说过,内容讲得不够深入,偏浅。这一点可能也是因为 Redis 的东西本来就不多吧,很多内容应该可以在网上找到的。还有就是,有些地方是可以不用代码的。比如在讲 HyperLogLog 时,为了说明 n 和 k 关系,贴了两段用于实验的代码,Java 和 Python 各一版。说实话,这些完全没有必要,并没有比用文字更清晰,也和讲解的内容关系不大,有点像是凑篇幅一样。如果作者认为确实很重要,可以放到附录里。此外,书中的一些代码的排版是有问题的,尤其原本 Redis 里的注释。源码的注释,在打印到书里,由于太长,会换行,但是换行还不多。比如这样,

    /* If we reached the 1:1 ratio, and we are allowed to resize
the hash
     * table (global setting) or we should avoid it but the ratio
between
     * elemenets/buckets .....
     */ 

真的很影响阅读体验,并且浪费纸。

总体而言,给7分吧。

neovim 插件 denote.nvim

neovim

hyperextensible Vim-based text editor。这是 neovim 官网对其的定义。目前,neovim 仍然在快速迭代,并且现在的版本(v0.3.5)已经可用。neovim 和 vim 在基本使用时兼容的,配置文件也基本是兼容的。因为我不是 vim 重度用 户,所以并没有发现 vim 中可用但是 neovim 中不可用的功能。目前我已经全部切换到 neovim 了。

denite.nvim

denite.nvim 是 neovim 的一个插件,在 vim 8 中也能使用。denite.nvim 利用了异步特性,性能相比上一代的unite.vim 有了很大的提升。denite 作者对其的描述是,“Dark powered asynchronous unite all interfaces for Neovim/Vim8”。说实话,从这句话,我是根本没明白这个插件到底做的是啥。不过看到一些推荐这个插件的地方,是用作 fuzzy finder 的,所以还是装了看了下。

简单地说,denite 是给 neovim 中提供了类似于 VSCode、Atom 中类似于 Ctrl-P 的功能。其实也有其他的插件提供类 似的功能,比如 fzf.vimctrlp.vim。不过我没有用过这些插件,所以本文不会对比。

denite 的配置比较复杂,幸而文档非常完善。denite 中,通过 source 收集不同的可以访问的对象。利用 :Denite 指令,列出 source 收集的对象,进而针对选择的对象,执行不同的操作。

先来个简单的例子,:Denite file/rec,查看当前目录下的所有的文件列表,rec 的意思是递归。所以,也有一个 file 的 source,只会显示当前目录下最顶层的文件。执行命令后,会有一个新的 buffer,列出所有的内容,称为 denite buffer。denite buffer 和普通的 vim buffer 不太一样, 可以认为是一个临时的 buffer。denite buffer 的操作方式和 vim buffer 也是不同的,但是也有两种模式,Insert 和 Normal。执行指令后,默认会进入 Insert 模式,denite 会根据用户的输入,过滤掉不匹配的候选项,默认的匹配方法是 fuzzy match,和 VSCode 中的 Ctrl-P 是一样的。选定候选之后,可以执行不同的操作,比如预览、打开、在另一个 buffer 打开等等。

source

source 是 denite 的核心概念。denite 可选择的候选都是由 source 提供的。简单而言,每个 source 提供了一个候选对象的(有序)集合。比如前边提到的 file/rec,内置的 source 还包括比如 colorscheme、buffer、command 等。source 可以有参数,在执行 :Denite 时,可以传递参数。比如 file/rec,可以接受一个参数作为搜索的目录,如果参数省略,则使用当前目录。除了参数,source 还可以通过 denite#custom#var 自定义一些变量(variable)。比如

call denite#custom#var('file/rec', 'command', ['git', 'ls-files', '-co', '--exclude-standard'])

这行代码自定义了 file/rec 这个 source 的 command 变量,这个变量是 source 获取文件列表的命令。在 Unix 环境下,默认的是使用 find 的。自定义之后,则变成了 git ls-files -co --exclude-standard。不同的 soruce 的参数以及可以自定义的变量都不同,具体的需要查看文档了。

denite buffer

source 收集的对象,都是列在 denite buffer 里,用户可以通过输入来过滤搜索结果。默认的设置下,进入 denite buffer 是处于 Insert 模式,这时用户可以进行输入过滤结果。在 Insert 模式下,输入 <tab>,可以选择一些可用的 action。不同的结果,可用的 action 也不同。结果又不同的类型,比如 file、buffer 等。不同的类型支持不同的 action 集合。

和 vim buffer 一样,denite buffer 也支持自定义 map。当然就不是 vim 默认的定义 map 的方式了。denite 中使用 denite#custom#map 定义 map。来个例子

call denite#custom#map('normal', '<tab>', '<denite:do_action:preview>', 'noremap')

这个例子,把 Normal 模式下的 <tab> 键,映射为预览操作。最后的 noremap 和 vim 中的意义一样,因为默认 denite 也会递归的解析映射的定义。定义了映射后,denite buffer 的操作和 vim 就没有区别了。现在 VSCode 中也是可以使用 vim 的,但是只能在编辑区。使用 Ctrl-P 的时候,可能还是不得不依赖方向键。在 denite 里就没有了这个问题。

预览主题的例子

denite 内置了 colorscheme 的 source,收集了所有已安装的主题。本来,在 vim 中切换主题,不是很方便,因为没有预览功能,只能 :color <theme> 选定主题,不合适就继续重复。而 denite 利用 -auto-action=preview 选项,配合 colorscheme source,可以方便的切换主题。

:Denite -auto-action=preview colorscheme`

-auto-action=preview 的意思是,对选中的结果,自动执行 preivew 的操作。-auto-action 是 denite 的 option。可以通过 denite#custom#option 设置自定义 option。默认设置下,-auto-action 是空的。

call denite#custom#option('default', 'auto_action', 'preview')

option 的名字需要改一下,去掉前缀的 - 并且把中间的 - 替换为 _

我的配置

nnoremap <leader><space> :Denite<space>

call denite#custom#alias('source', 'file/rec/git', 'file/rec')
call denite#custom#var('file/rec/git', 'command', ['git', 'ls-files', '-co', '--exclude-standard'])
nnoremap <silent> <C-p> :<C-u>Denite -auto-action=preview 
            \ `finddir('.git', ';') != '' ? 'file/rec/git' : 'file/rec'`<cr>
nnoremap <leader>c :<C-u>Denite colorscheme -auto-action=preview<cr>
nnoremap <leader>; :<C-u>Denite file_mru<cr>

call denite#custom#map('insert', '<tab>', '<denite:move_to_next_line>', 'noremap')
call denite#custom#map('insert', '<S-tab>', '<denite:move_to_previous_line>', 'noremap')
call denite#custom#map('insert', '<C-cr>', '<denite:choose_action>', 'noremap')
call denite#custom#map('insert', 'jj', '<denite:enter_mode:normal>', 'noremap')

call denite#custom#map('normal', '<tab>', '<denite:do_action:preview>', 'noremap')
call denite#custom#map('normal', '<S-tab>', '<denite:choose_action>', 'noremap')

call denite#custom#option('default', 'winheight', '15')

有两点说明下。其中 file/rec/git 相关,是 denite 的 FAQ 中的方案。因为默认情况下,file/rec 会显示所有目录下的文件,包括 .git 目录,直接用 file/rec 的结果并非期望的结果,所以当存在 .git 目录时,用 git ls-files 获取文案列表。配置中使用了一个 file_mru 的 source,提供的是最近使用的文件的列表,这个 source 不是 denite 内置的,是由插件 neomru 提供的,与 denite 是同一个作者 Shougo。

总结

denite 的配置难度比较高,但是配置好之后,用起来就非常顺手。另外,neovim 一个很重也好的发展方向,就是 embeded everywhere,也就是嵌入各种地方。给 neovim 套一个更现代的一个壳,是我对 neovim 的一个非常大的期待。而 denite 给现代的壳,提供了一个非常好的实现 Ctrl-P 的方式。

在只有类名时使用 Jackson 反序列化 Java 对象

反序列化

Java 里,用 Jackson 序列号和反序列化,非常简单。序列化不是本文关心的内容,不谈。反序列的接口,基本下边这两个接口。

<T> T readValue(String content, Class<T> valueType);
<T> T readValue(String content, TypeReference valueTypeRef);

使用 Class<T> 的接口,返回的是一个类型为 T 的对象。接口的声明很直接,使用也比较舒服。不过当需要反序列的是带有参数的类型,比如 List 等,这个接口提供的信息就缺失了一些,那就是 List 的类型参数。在语法上,readValue(s, List<Type>.class) 是行不通的。而,List<Dog>List<Cat> 显然不同,在反序列化时,我们期望得到元素类型不同的 List

Java 中,不允许 List<Type>.class 的写法,是因为类型擦除。编译之后,只有 List 这个 rawType。为了解决这个问题,Jackson 提供了 TypeRefernece,来保存类型参数信息。既然可以保存,显然,类型擦除并没有完全地把所有的类型参数的信息都丢掉。实际上,可以通过反射来获取类的泛型信息,方法是通过 Class#getGenericSuperclass 获取泛型父类,返回的类型是 Type。如果父类实际上没有类型参数,则实际上是个 Class,否则,是 ParameterizedType。看下 TypeReference 的具体实现,

// 删除了无关代码、注释、空行
public abstract class TypeReference<T> {
    protected final Type _type;
    protected TypeReference() {
        Type superClass = getClass().getGenericSuperclass();
        if (superClass instanceof Class<?>) { // sanity check, should never happen
            throw new IllegalArgumentException("Internal error: TypeReference constructed without actual type information");
        }
        _type = ((ParameterizedType) superClass).getActualTypeArguments()[0];
    }
    public Type getType() { return _type; }
}

注意 TypeReference 是一个抽象类,这意味着实例化时都是要创建一个子类。有了 TypeRerence,反序列化就没有问题了。

没有类型的具体信息

这里其实还有个问题,假如我没有类的实际信息,只有类的名字以及类型参数的名字,怎么来反序列化呢?我碰到这个问题,是在尝试写一个简单的 RPC,序列化方式就是用的 json。在传递参数时,客户端只能把参数的类型通过字符串传递,需要在服务端解析出来。

TypeReference 的构造函数,要求必须有类型参数。然而,即使通过类名,获得了对应的 Class 实例,也没有办法转变成字面量去创建一个 TypeReference。解决办法其实很简单,重写 getType 方法,因为 TypeReference 的目的就只是提供一个 getType 方法。

class CustomeTypeRef<T> extends TypeReference<T> {
        private Class<?> rawType, paramType;
        protected MyTypeRef(Class<?> rawType, Class<?> paramType) {
            this.rawType = rawType;
            this.paramType = paramType;
        }
        @Override
        public Type getType() {
            return new ParameterizedType() {
                @Override
                public Type[] getActualTypeArguments() {
                    return new Type[]{paramType};
                }
                @Override
                public Type getRawType() {
                    return rawType;
                }
                @Override
                public Type getOwnerType() {
                    return null;
                }
            };
        }
    }
}

虽然参数类型 T 已经不需要了,但是必须得有。因为在 TypeReference 的默认构造函数,强制必须有参数类型。

看起来很简单的样子啊。

Default method in interface

Java 中的interface是一组抽象方法的集合,是一组对外的接口。Java 8 之前,在interface中是没有方法体,的,只能在子类实现。如果是所有子类都一致的实现,标准方法是定义一个实现这个接口的抽象类,在抽象类中实现公共方法,子类集成这个抽象类,并实现各子类不同的方法。这样的写法并没有什么问题,只是啰嗦而已。在 Java 8 中,接口内可以实现 defaut method。所有实现改接口的类,如果没有重写这个方法,则使用接口中的实现。针对上面的场景,可以少写一个类。

此外,接口和抽象类的一个重要区别在于,一个类只能继承一个父类,却可以实现多个接口。在没有 default method 的时候,多个接口表示我们在子类中实现的方法变多了。假如一个接口内只有 default method,那么继承这个接口的类可以不用实现接口的方法,而自动的获得了一个方法。在一些场景下,可以利用这个方法给类增加一些 utils 方法。比如下边的代码,Person只需要继承Jsonable就可以自动的获得toJson方法。

interface Jsonable {
    default String toJson() {
        return JsonSerializer.serialize(this);
    }
}
class Person implements Jsonable {
    private int id;
    private String name;
}

一些简单的 utils 方法,就可以不必定义一个 utils 类了。当然这是一种口味的问题,并不是所有人都喜欢这样一种方法的。此外,之所以说简答的 utils 方法可以这样实现,原因在于 java 的 interface 中不能有状态,所以有些是做不了的。比如缓存、log 等。并且现在 java 还是不支持接口中的私有方法,所以写一些复杂的方法在接口中,会暴露不必要的细节。好消息是 Java 9 已经支持了接口中的私有方法。

default method 是一种 mixin,不过功能有限。而一些语言则提供更完善的 mixin 机制,比如 scala 的traittrait是可以有状态的,从而对子类的的侵入会更少。对于 mixin 还不是很了解,可以参考下边的链接。

参考:
traits-java-8-default-methods
what-are-the-differences-and-similarties-between-scala-traits-vs-java-8-interfaces
Mixin是什么概念?

从Jekyll迁移到Hugo

最近把博客从Jekyll迁移到了Hugo,在这里记录一下。

之前一直使用Jekyll,最大的原因是Github Pages原生支持Jekyll, repo里只管理源代码就可以,不需要上传build之后的文件。 不过Jekyll也有许多不尽如人意的地方,主要是一下几点:

  • 本地开发环境不容易配置。 没有直接的可执行文件,需要安装ruby,gem,之后再安装jekyll
  • Github Pages的build配置不能按照自己的需求定制。 各种依赖的版本不能自己选择,也不能根据使用Jekyll的一些插件
  • 编译速度慢。 因为本站的文章太少,这一点倒是不是什么问题

内容迁移

博客从Jekyll迁移到Hugo,在考虑主题迁移的情况下,还是比较简单的。 Hugo的命令行可以直接从Jekyll导入文章,

hugo import jekyll /path/to/jekyll/root /target/path

这样可以直接导入文章,Jekyll中其他的静态文件会被放到static文件夹中。 这个命令比较简单,但并没有把所有事情干完。只是把Jekyll中的文章放到了Hugo中, 并且根据文件名里的时间,放到了front matter中,其他并没有改动。 文件名也仍然需要自己手动修改,去掉时间。

此外,Hugo使用的markdown,在语法上也与Jekyll有些差异,渲染出来的html也是有些不同。 这里可以在Hugo的配置文件里进行修改,也可以修改文章的语法。

自动编译

使用Jekyll的时候其实是不需要这一步的,直接推到github上就行了。 使用hugo,如果每次都是本地编译,然后把编译后的html文件推到github, 那就太不方便了。而且这样也不利于源码文件的管理。

Travis CI

Travis CI是持续集成服务,并且对于Github上的public repo是免费使用的。 利用Travis CI,可以达到每次push到Github上的时候,自动build, 并推送到repo的特定的分支。

首先需要在Travis CI上开启repo的自动集成,然后在repo里添加.travis.yml。 Travis CI会根据这个文件,进行build。根据Travis CI的文档, build过程分为几个阶段。一个静态博客的build,比较简单, 也不用所有的过程都用上。在install阶段安装hugo,script阶段调用hugo进行编译, after_success阶段把生成的文件推到github上。这是本站的使用的配置。

# .traivs.yml
language: go
go:
 - '1.10'
branches:
  only:
  - source # 只有source分支的推送才触发构建
install:
  - wget /path/to/hugo/releases -O /tmp/hugo.tar.gz
  - mkdir -p bin
  - tar -xvf /tmp/hugo.tar.gz -C bin
script:
  - bin/hugo
after_success:
  - sh .travis/push.sh
# push.sh
setup_git() {
    git config --global user.name "travis@travis-ci.org"
    git config --global user.email "Travis CI"
}

commit_files() {
    git init
    git add .
    git commit -m"Travis build: $TRAVIS_BUILD_NUMBER"
}

push() {
    git remote add origin https://${GH_TOKEN}@github.com/yourname/yourrepo.git
    git push -f -u origin master
}

cd public
setup_git
commit_files
push

由于Travis CI并没有repo的push权限,所以直接推到repo上是会验证失败的。 push.sh里,GH_TOKEN是有public_repo权限的personal access token, 可以在https://github.com/settings/tokens申请,并在Travis CI上设置。

至此,Travis CI的自动build就完成了。

Synchronized的内存屏障

问题

在V2EX上看到这样一个问题,具体来说,就是下面这份代码,注释和不注释,为什么运行会有不同

public class MyRun implements Runnable {

	private boolean stop;

	MyRun(boolean status) {
		this.stop = status;
	}

	@Override
	public void run() {
		while(!stop) {
			// System.out.println("running");
		}
		System.out.println("stop");
	}

	public void setStop(boolean stop) {
		this.stop = stop;
	}
}

// 测试代码
MyRun myRun = new MyRun(false);
new Thread(myRun).start();
Thread.sleep(1000); // 等待线程执行
myRun.setStop(true);

这个代码目的就是通过主线程修改变量,控制子线程的运行。 为了这个目的,很显然stop需要添加volitale关键字,表明stop是多线程可见的。 那么,子线程在读取stop的时候,会从先把主内存的变量同步到自己的工作内存,然后再使用, 因而可以拿到最新的stop的值。

抛开volatile不谈,单独这份代码,注释和不注释下,运行结果也有很大差异。

  • 注释的情况下,子线程没有得到stop的最新值,其工作内存中的stop一直是false,因此程序死循环。 这和预期情况一致。
  • 不注释的情况下,程序会一直输出running,知道1秒后,输出stop。显然子线程获得到了stop的最新值。 这里的我就不太理解了,为什么呢?

syncronized

最开始我以为是IO引起的用户态内核态切换,会导致从主存中同步,不过查了一圈资料,这个猜想是错误的。

println函数在jdk里的实现是这样的

public void println(String x) {
    synchronized (this) {
        print(x);
        newLine();
    }
}

里面有个synchronized,估计就是和这个有关了。

手头有本《深入理解Java虚拟机》(简称书),里边关于Java的内存模型, 有这样的说法

同步块的可见性是由“对一个变量执行unlock操作之前,必须先把此变量同不会主内存中(执行store、wirte操作)”这条规则获得的。

但是这个说法和这里用法不一样,因为书中说法,意思是退出同步块之前,要把synchronized的对象同步会主内存。 而本问题中,同步块锁住的对象this,是指System.out这个对象,并不是myRun

JSR 133 FAQ中,有如下说法

Before we can enter a synchronized block, we acquire the monitor, which has the effect of invalidating the local processor cache so that variables will be reloaded from main memory. We will then be able to see all of the writes made visible by the previous release.

这说明synchronzed可以是使本地CPU缓存失效,从而从主内存中读取最新的变量值。 但是后面的有一个*Important Note*,表明只有释放和获取的是同一把锁,才能保证happen before关系, 又让我对这段胡的理解产生了疑问。 在stackoverflow上,有一个关于这段话的提问,但是并没有让我更明白。

之后又去看Java语言规范中关于内存模型的部分。 在Java语言规范17.1节,关于synchronized块,有如下说明

attempts to perform a lock action on that object’s monitor and does not proceed further until the lock action has successfully completed

这里的一个重点是lock action,这章中只说明lock的意思是locking a monitor,并没有具体的解释。 书中写到Java内存模型有8个操作,其中一个就是lock,但是Java语言规范中并没有相关说明。 最后在Java6的虚拟机规范第8章中,才找到对其的说明,并有一个对于本问题的重要的规则

Let T be any thread, let V be any variable, and let L be any lock. There are certain constraints on the operations performed by T with respect to V and L:

  • Between a lock operation by T on L and a subsequent use or store operation by T on a variable V, an assign or load operation on V must intervene; moreover, if it is a load operation, then the read operation corresponding to that load must follow the lock operation, as seen by main memory. (Less formally: a lock operation behaves as if it flushes all variables from the thread’s working memory, after which the thread must either assign them itself or load copies anew from main memory.)

这个规则说明,synchronized可以保证其工作内存中的变量都是最新版本。对于本问题,对System.out的锁, 更新了工作内存中的值,从而退出循环。

不过,在Java7和Java8的虚拟机规范中,这一章被移除了,并将相应的内容放到了Java语言规范中, 也就是上文所引用的第17章中。但是我并没有在其中找到与这个规则具有相同意义的规则。 不知道哪里漏了。

变体

把问题中的run方法改一下,变成

public void run() {
    while(!stop) {
        try {
            Thread.sleep(1);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
    System.out.println("stop");
}

实际上也是会最后输出stop的。但是Java语言规范中明确表示,

It is important to note that neither Thread.sleep nor Thread.yield have any synchronization semantics. In particular, the compiler does not have to flush writes cached in registers out to shared memory before a call to Thread.sleep or Thread.yield, nor does the compiler have to reload values cached in registers after a call to Thread.sleep or Thread.yield.

也就是说Thread.sleep是不需要刷新工作内存的。 但是这里仍然打印了stop,说明在某种情况下,线程冲主内存同步了变量。 由于这并不是Java的规范,所以这是和JVM的具体实现相关,因此并不能依赖于这一点。

总结

Java的内存模型之前看过,但是并不是非常清楚。这次前后查了好多,也有了更多的理解。 并且还有个问题并没有搞清楚,Java8的规范里,哪条规则能够明确的推导出Java6关于lock的规则。 这个就慢慢再看吧

Updated

原贴下有贴出了一个链接,感觉说得刚靠谱。JVM虚拟机做了优化,会尽可能的保障工作内存与主内存的同步。 这样就解释了synchronizedsleep时,线程能够获取到最新变量。

想想还是太naive了,还是要多学多看啊

JDK源码阅读之String

这几天看了看Java的String的实现。Java中的所有的String字面量都是String类的实例。 文件注释中写到了,字面量生命String s = "abc"

char[] data = new char[]{'a', 'b', 'c'};
String s = new String(data)

效果是一样的。这应该是JVM来实现的。

接口

String实现了三个接口,分别是SerializableComparable<String>CharSequenceSerializable用于序列化,Comparable<String>用于比较, CharSequence则是String的一个更通用的抽象。

属性

String最重要的属性是private final char value[]value中存放着String的实际内容。 此外,Java的char的长度是16bit,两个字节。 另外一个属性是private int hash,是String得哈希值,默认为0。hash在调用hashCode()时计算, 因此不是final

构造方法

String的构造方法分为几类 * 无参构造方法,得到的就是空的字符串 * 参数是String的,直接对valuehash赋值 * char[]相关的,这类方法进行越界检查,由于String是不可变的,还会复制数组 * int[]相关的,这里的参数是Unicode的codePoint,因为Unicode是4字节的,所以使用了int。 对于基本平面(BMP)的字符,只需要char即可。对于辅助平面的字符,一个codePoint,需要两个char才能存下。 * byte[]相关的,所有从byte[]转为String的,都需要指明编码格式。 曾经有过不需要指明编码格式的方法,但是现在已经Deprecated了,因为有bug。 * StringBuilderStringBuffer,具体实现上都是复制数组,StringBuffer加了锁 * String(char[] value, boolean share),这是一个特殊的构造方法,这个方法可访问性是包内可见。 为了性能上的考量,实现上不做数组复制,只是简单的赋值。调用的时候,share一定是true

方法

所有方法方法中,涉及到可能产生新的字符串的,都会先检查参数,是否可以直接返回自身。

  • length直接返回value.length
  • isEmpty判断value.length == 0
  • hasCode返回hash,如果没有计算过,用times31算法计算,并保存结果
  • equals,不比较hashCode,直接按序比较字符
  • 其他比较相关的方法,
    • 定义了内部静态类CaseInsensitiveCompartator,用于处理大小写不敏感的比较
    • 基本上都是从前向后遍历
    • compareTo方法是按照Unicode字典序比较的,有不同则返回不同的字符的差,否则返回长度的差
  • indexOf(int ch, int fromIndex)
    • indexOf(ch) == indexOf(ch, 0)
    • 先判断ch,如果是负值(非法值)或者BMP字符,从前到后扫一遍
    • 否则是辅助平面字符,从前到后扫,比较前导代理以及后尾代理
  • indexOf(String s, int fromIndex)
    • 实现上,先找到第一个字符,然后比较余下字符。不断循环
    • 效率上比较低下,stackoverflow有个讨论,我觉得还是有道理的
  • contains,判断indexOf() > -1
  • matches,调用Pattern.matches
  • split(String regex, int limit)
    • limit表示分割后的数组的长度,若0,表示不限制结果的个数。默认为0
    • 实现上,如果regex是简单的字符串
    • 单个字符,并且不是正则表达式的元字符
    • 两个字符,第一个是'\\',并且第二个不是ascii字母和数字
    • 从前到后扫,调用indexOf
    • 否则调用Pattern
    • 空的字符串会返回
    • 但是,split方法有个坑,就是最后一个分隔符后面如果没有其他字符,那么是没有最后一个空字符串的
    • "hello,,yes,".split(",") == ["hello", "", "yes"]
  • join,静态方法,调用StringJoiner
    • null会按照"null"处理
  • concat(str),不检查参数,如果为null会报异常,如果str.length != 0,开辟新的数组。
    • 只调用一次数组复制,
  • substring,检查之后复制数组。之前某个版本好像是没有复制数组,导致了内存泄漏
  • trim,实现上是去除了前后的所有ascii码小于等于20的字符
  • replace
    • 字符替换,如果相同或者未发现,直接返回,否则遍历
    • 字符串替换,调用Pattern
  • 大小写转换相关方法的实现,考虑的东西比较多,实现比较复杂。涉及了Locale,未知名则使用系统默认的。 不同的语种,大小写规则不太一样,调用了ConditionalSpecialCasing进行实际的转换
  • toString,返回自己
  • toCharArray,调用System.arraycopy产生新的数组
  • valueOf系列
    • char[],调用构造函数
    • Object"nul"或者toString()
    • 内置类型,直接调用响应的toString()
  • native intern(),将自身添加到字符串池

2017之前

2016年,干了几件事,出去实习了,找到工作了,以及毕设相关。

先说实习,第一次是在腾讯。虽然我进去的之后的岗位也是研发实习生,但这个部门其实并不是一个做研发的部门,是做直播的,腾讯视频的一些的直播的技术支持部门,主要是体育赛事,还会有一些演唱会、新闻等等。由于不是研发部门,其实进去之后首先熟悉了演播室的直播设备,跟着值班了一段时间。除却这些直播技术,部门还负责直播的在线包装、场景设计,使用的广电行业的专用软件,Viz。我感觉Viz还是非常复杂,有各种插件用于实现各种功能,当这些插件无法实现所需要的功能时,Viz还提供脚本支持,可以编写一些复杂的动画、人机交互界面等等。这个脚本还是比较简单的,当时的leader说这个脚本其实就是VB差不多。Viz脚本其实想要招程序员的一个最重要的一点。不过由于我在腾讯的实习其实还是比较短,Viz脚本也没学多少。后来实习快结束的时候,又给我了一个开发一个内部使用的系统的任务。从2015年12月末开始,在腾讯实习了3个月多。由于不是研发部门,对研发的情况不是很了解,就我自己所在的部门,气氛是比较轻松的,也没有特别严格的作息要求。腾讯有专门的内部技术论坛,供工程师交流。

从腾讯离职之后,由师兄内推,又去了微软实习。部门是微软小冰。进去以后,我的座位是由一个会议室改的,是一个单间,与mentor的办公位不在一起,而且做的项目基本上是一个人项目,所以和其他同事的交流不多。实习期间,做了两个项目,一个是内部数据的在线编辑管理的系统吧,另一个是爬虫。第一个项目,实质上是增删改查,不过需要先把原有的文本文件的数据倒进来。而且需要有个界面,我选了Vue做前台,这里其实就是自己的选择,没有过多的比较,而且在腾讯做的项目也是用了Vue,还没忘光。第二个爬虫是mentor自己从头写的,我要做的就是增添一些功能。通过ssh远程在服务器上写,原因是用了一些库,只能在Linux上运行。因为这,把vim又好好学了一下。在微软实习,还是很轻松,没有压力,任务完成就行,mentor人也好。之后由于要开始找工作了,而且也要做毕设,就离职了。

大约8月初,各种内推就开始了,直到10月末签了三方,足足3个月。而且必须吐槽网易带了个好头,内推不免笔试,结果都成这个套路了。这段时间,除了吃饭睡觉,就是笔试面试和准备笔试面试。leetcode刷了绝大部分,没有像同学一样其他第二遍。还有就是各种基础吧,主要是笔试面试啥都问,数据库、网络、操作系统、jvm、Java的多线程、JDK的常用集合的实现各种各种,上到高并发设计,下到内存页表。经常投的Java岗,结果笔试还是一堆的c++、php。

我也也参加了不少面试,给我感觉最好的是Google和AirBnb,两者风格完全不同。Google应该是面试人数比较少,面试前后都会有电话通知,然后我就收到可面试挂了的电话。。面试官是外国人,为了面试专门飞过来的,很有经验,题目就是算法题,白板写。AirBnb 的面试官是中国人,但是面试要求说英语,一轮45分钟,大约15分钟的题目,剩下的30分钟写代码,是可运行的环境。这个是我最喜欢的方式。面试给我感觉不好的事华为和网易。华为是两面很奇怪,根本没有问一些有区分度的问题,结果就跪了。网易的面试过程还可以,但是安排比较乱,面试官和HR之间的信息沟通不畅,中间空等了好长时间。还有滴滴,虽然我没参加,但是今年的面试安排真的是一团shi。

至于论文,目的就是毕业了。自己对做科研还是没有太大的兴趣,写代码做工程更适合我。答辩时间改到明年3月,希望一切顺利!

用Rust写了一个简单的Web服务器

Rust

最近学了一阵Rust,这个语言的目的是系统编程,卖点是无GC的内存安全。为了实现这一点,Rust引入了所有权、借用、生命周期的概念。可以在编译器检查出可能的内存问题,如野指针、局部变量指针等等。不过这也对写程序造成了一定的困扰,对于move、borrow等如果理解的不是很到位,那必然要和编译器做长期的斗争。

Web服务器

骨架

Web服务器,实际上就是对socket的数据流的处理,监听端口,并对每个新的连接,开启一个新的线程进行处理。代码的骨架基本上是

match TcpListener::bind("127.0.0.1", 9999)) {
    Ok(listener) => {
        for stream in listener.incoming() {
            match stream {
                Err(e) => {
                    // error, log, ignore
                },
                Ok(s) => {
                    thread::spawn(move || handle_client(s));
                },
            }
        }
        drop(listener);
    },
    Err(e) => {
        // error, log, ignore
    }
}

其中thread::spawn(move || handle_client(s)),开启新的线程,参数是一个闭包,move关键字表示将闭包所在环境的标量的所有权强行交给闭包。之后重点是handle_client中对于TcpStream的处理,也就是解析请求,并构造响应。读取请求。

解析请求

一个HTTP的请求,格式是这样的

METHOD URI VERSION
Host: xxx
other-header: xxx

body

这个服务器目前只能处理GET和HEAD请求,并且只能处理静态文件,所有很多东西并没有做。比如querystring的解析、请求体的解析等等。各种header也只是解析,并没有真的使用。之后会慢慢完善,函数重点是

fn parse(stream: &mut TcpStream) -> Option<Request> {
    let mut s = Vec::new();
    Self::get_request(stream, &mut s);
    match String::from_utf8(s) {
        Ok(s) => {
            // parse request line and header
        },
        Err(_) => None,
    }
}

如果解析失败,返回一个None,这是Request结构的一个静态方法。解析成功则打印日志,并根据请求构造响应。

构造响应

响应的的格式为

VERSION CODE PHRASE
header: xxx
other-header: xxx

body

由于只能处理静态请求,实际上这里就是读取文件并.对于HEAD请求,只计算长度,没有响应体部分。

目前的相应的结构为

struct Response {
    head: String,
    body: String
}

通过code、mime、content等拼接字符串,得到响应头部以及响应体。最后通过TcpStream发送出去。

至此,这个web服务器就算是完成了。

最后

Rust这个语言还是非常不熟,对于lifetime的理解也太行,所以通篇没有用到lifetime标记,遇到字符串都是用的String。另外,Rust目前并没有高性能的非阻塞IO以及异步IO,有一些库在做这方面的尝试。不过对这方面不熟,没有多做尝试。

最后,项目的地址是https://github.com/iEverX/rock

利用注解实现依赖注入

准备

依赖注入是啥?

提到依赖注入(Denpendency Injection,DI),得先讲控制反转(Inversion of Control,IoC)。控制反转是一种设计原则,目的是去除代码的去耦合。通常写程序,我们会在类中实例化所需的对象,比如说

class Car {
    Tier tier = new Tier("A");
}

这里,Tier就是Car的一个依赖。像这种代码会造成一个问题,那就是TierCar之间是耦合在一起的。假如Tier的实现变了,增加了新的构造函数,原来的无参构造函数不满足Car的需求,那么就还需要修改Car的代码。如果换个方式,把代码改成下面这样

class Car {
    Tier tier;
    public void setTier(Tier tier) {
        this.tier = tier;
    }
}

那么就可以通过事先实例化一个Tier对象,通过setTier方法传给Car对象,Car的代码完全不需要修改。这就是控制反转,所谓反转,意思是依赖的控制被反转了。之前,依赖的生成有对象控制,现在依赖的生成由外层代码控制。上面的采用set方法的方式就称为依赖注入,还可以通过构造函数,或者通过接口实现。

注解

注解(Annotation)是Java在1.5版本提供的特性,通过注解可以给JVM提供额外的信息。这些额外的信息,可以在运行时获取,从而改变代码的行为。

代码实现

为了实现依赖注入,需要有以下几个东西

  • 标识一个属性通过外部注入的注解
  • 根据注解注入对象的代码
  • 一个保存组件的容器,以及生成的组件

其中最后一点就是Spring中的component-scan功能,不过我不会实现,所以本文的最后一点是手工完成的。

注解

代码很简单

@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface Inject {
    String value() default "";
}

这就OK了,一个注解就是这么简单。这里声明了一个名为Inject的注解,其关键字为@interface。与普通的接口不一样的地方是,不允许有属性,只能有方法,且方法不能有参数。此外,方法后可以跟一个default说明默认值。

在注解之上的TargetRetentionDocumented同样是注解,这些注解称为“元注解”,共有4个,除了以上三个还有一个Inherited。元注解用于对注解进行类型说明。

  • Target指明注解的使用范围,这里的ElementType.FIELD表明Inject可以注解属性,可选的值还包括TYPEPARAMETER
  • Retention指明注解的保留期限,RUNTIME表明在运行时可以获取注解信息。可选值还有SOURCECLASS,分别表示在源码和字节码中保留注解信息
  • Documented用来指明注解应该被文档化,指示javadoc之类的工具应该生成该注解的文档
  • Inherited指明注解可以被继承

Inject的定义很简单,其实可以更简单,那就是直接用Java自带的注解,比如Resource。因为注解本身不提供功能,注解功能的实现是由其他代码读取注解信息从而完成的。

使用注解

public class Car {

    @Inject
    private Tier tier;
    
    @Inject("james")
    private Driver driver;

    public void run() {
        System.out.println("A car is running, driver is " + driver.getName() + ", and its tier's brand is " + tier.getName());
    }
}

Tier的注解没有参数,说明给的是默认值,driver的注解加了参数,但是没有指明是哪个参数,这种情况下,默认使用value,当有多个参数时,不允许省略value。

读取注解并注入

static void inject(Object obj, Map<String, Object> container) {
    Field[] fields = obj.getClass().getDeclaredFields();
    for (Field field : fields) {
        field.setAccessible(true);
        Inject inject = field.getAnnotation(Inject.class);
        if (inject != null) {
            String name = inject.value();
            if (name.isEmpty()) {
                name = field.getName();
            }
            if (!container.containsKey(name)) {
                throw new RuntimeException("Object \"" + name + "\" cannot be found in container.");
            }
            try {
                field.set(obj, container.get(name));
            } catch (IllegalAccessException e) {
                // ignore
            }
        }
    }
}

这段代码通过反射获取一个类的所有字段,并获取字段上的Inject注解。如果有注解的情况下,依次根据注解的value以及属性的名字获取注入的对象名。并通过发射将对象赋给相应的属性。

实际运行

Map<String, Object> container = new HashMap<String, Object>();
container.put("james3", new Driver());
container.put("tier", new Tier());

Car car = new Car();
inject(car, container);
car.run();

在这里,通过inject方法将container中的对象根据需要注入到car中,无需car去管理对象的生成。注意到,这里的对象实例化都是有自己手动完成的。而且在实例化car时,依然自己手动调用了inject方法。所以这里简略的实现了一个依赖注入。为了自动实现以上想法,需要把car也放到container中。而container也应自动生成,可以通过扫描指定的包下的类来实现。个人感觉这里比较负责,不是很好写。具体可以参考Spring的实现。

总结

使用注解可以极大的增强代码的灵活性,而且使用注解也并不复杂,通过几个简单地API就可以完全搞定,真的是so easy!