开发者博客 – IT技术 尽在开发者博客

开发者博客 – 科技是第一生产力


  • 首页

  • 归档

  • 搜索

Java—线程同步

发表于 2017-12-12

几个小概念

临界资源:当多线程访问同一个对象时, 这个对象叫做临界资源

原子操作:在临界资源中不可分割的操作叫原子操作

线程不安全:多线程同时访问同一个对象, 破坏了不可分割的操作, 就可能发生数据不一致

“弱肉强食”的线程世界

大家好,我叫王大锤,我的目标是当上CEO…额 不好意思拿错剧本了。大家好,我叫0x7575,是一个线程,我的线生理想是永远最快拿到CPU。

先给大家介绍一下线程世界,线程世界是一个弱肉强食的世界,资源永远稀缺,什么东西都要抢,这几个纳秒我有幸拿到CPU,对int a = 20进行一次加1操作,当我从内存中取出a,进行加1后就失去了CPU,休息结束之后准备写入内存的时候,我惊奇的发现:内存中的a这时候已经变成了22。

一定有线程趁我不在修改了数据,我左右为难,很多线程也都劝我不要写入,但是迫于指令,我只能把21写入内存覆盖掉不符合我的运算逻辑的22。

以上只是一个微小的事故,类似的事情在线程世界层出不穷,所以虽然我们每一个线程都尽职尽责,但是在人类看来我们是引起数据不安全的祸首。

这是何等的冤枉啊,线程世界一直都是竞争激烈的世界,尤其是对于一些共享变量,共享资源(临界资源),同时有多个线程进行争夺使用时再正常不过的事情了。除非消除共享的资源,但是这又是不可能的,于是事情就开始僵持了。

线程世界出现了一把锁

幸好还是又聪明人的,有人想到了一个解决问题的好方法。虽然不知道谁想到的注意,但是这个注意确实解决了一部分问题,解决的方案是加锁。

你想要进行对一组加锁的代码进行操作吗?想的话就先去抢到锁,拿到锁之后就可以对被加锁的代码为所欲为了,倘若拿不到锁的话就只能在代码块门口等着,因为等的线程太多了,这还成为了一种社会现象(状态),该社会现象被命名为线程的阻塞。

听上去很简单,但是实际上加锁有很多详细的规定的,详情政府发布了《关于synchronzied使用的若干规定》以及后来发布的《关于Lock使用的若干规定》。

线程和线程之间是共享内存的,当多线程对共享内存进行操作的时候有几个问题是难以避免的,竞态条件(race condition)和内存可见性。

**竞态条件:**当多线程访问和操作同一对象的时候,最终结果和执行时序有关,正确性是不能够人为控制的,可能正确也可能不正确。(如上文例子)

上文中说到的加锁就是为了解决这个问题,常见的解决方案有:

  • 使用synchronized关键字
  • 使用显式锁(Lock)
  • 使用原子变量

**内存可见性:**关于内存可见性问题要先从内存和cpu的配合谈起,内存是一个硬件,执行速度比CPU慢几百倍,所以在计算机中,CPU在执行运算的时候,不会每次运算都和内存进行数据交互,而是先把一些数据写入CPU中的缓存区(寄存器和各级缓存),在结束之后写入内存。这个过程是及其快的,单线程下并没有任何问题。

但是在多线程下就出现了问题,一个线程对内存中的一个数据做出了修改,但是并没有及时写入内存(暂时存放在缓存中);这时候另一个线程对同样的数据进行修改的时候拿到的就是内存中还没有被修改的数据,也就是说一个线程对一个共享变量的修改,另一个线程不能马上看到,甚至永远看不到。

这就是内存的可见性问题。

解决这个问题的常见方法是:

  • 使用volatile关键字
  • 使用synchronized关键字或显式锁同步

线程同步

传统的锁 synchronzied

同步代码块

每个java对象都有一个互斥锁标记,用来分配给线程,synchronized(o){ } 对o加锁的同步代码块,只有拿到锁标记的线程才能够进入对o加锁的同步代码块。

同步方法

synchronized作为方法修饰符修饰的方法被称为同步方法,表示对this加锁的同步代码块(整个方法都是一个代码块)。

JDK1.5的锁 Lock

ReentrantLock

ReentrantLock具有和synchronized相似的作用,但是更加的灵活和强大。

它是一个重入锁(synchronized也是),所谓重入就是可以重复进入同一个函数,这有什么用呢?

假设一种场景,一个递归函数,如果一个函数的锁只允许进入一次,那么线程在需要递归调用函数的时候,应该怎么办?退无可退,有不能重复进入加锁的函数,也就形成了一种新的死锁。

重入锁的出现就解决了这个问题,实现重入的方法也很简单,就是给锁添加一个计数器,一个线程拿到锁之后,每次拿锁都会计数器加1,每次释放减1,如果等于0那么就是真正的释放了锁。

1
2
3
4
5
6
7
8
9
10
11
复制代码//创建一个锁对象
Lock lock = new ReentrantLock();

//上锁(进入同步代码块)
lock.lock();

//解锁(出同步代码块)
lock.unlock();

//尝试拿到锁,如果有锁就拿到,没有拿到不会阻塞,返回false
tryLock();

ReadWriteLock

读写锁,读写分离。分为readLock和writeLock两把锁。对于readLock来说,是一把共享锁,可以多次分配;但是当readLock锁上的时候,调用writeLock是会阻塞的,反之亦然,另,写锁是一把普通的互斥锁,只可以分配一次。

synchronized和ReentrantLock的区别
  1. 两者都是互斥锁,所谓互斥锁:同一时间只有一个拿到锁的线程才能够去访问加锁的共享资源,其他的线程只能阻塞
  2. 都是重入锁,用计数器实现
  3. ReentrantLock独有特点
    1. ReenTrantLock可以指定是公平锁还是非公平锁。而synchronized只能是非公平锁。所谓的公平锁就是先等待的线程先获得锁
    2. ReenTrantLock提供了一个Condition(条件)类,用来实现分组唤醒需要唤醒的线程们,而不是像synchronized要么随机唤醒一个线程要么唤醒全部线程
    3. ReenTrantLock提供了一种能够中断等待锁的线程的机制,通过lock.lockInterruptibly()来实现这个机制
volatile关键字

volatile 修饰符 用来保证可见性

当一个共享变量被volatile修饰的时候,他会保证变量被修改之后立马在内存中更新,另一线程在取值的时候需要去内存中读取新的值。

注意:尽管volatile 可以保证变量的内存可见性,但是不能够保存原子性,对于b++这个操作来说,并不是一步到位的,而是分为好几步的,读取变量,定义常量1,变量b加1,结果同步到内存。虽然在每一步中获取的都是变量的最新值,但是没有保证b++的原子性,自然无法做到线程安全

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

【php审计实战篇】BlueCms v16 Union注入

发表于 2017-12-12
0x00 前言

非常基础的代码审计练习,适合有php基础的审计新手练习

本文作者:Aedoo

来源:i春秋社区

0×01 代码跟踪

首先,进入首页代码 :index.php

1-2-630x290.png

包含了php文件:/include/common.inc.php

跟踪这个php文件,这些文件都是包含的全局文件。

2-3.png

这个php文件还是先包含了几个全局文件。

最主要的是上图最下方的if判断:

1
2
3
4
5
6
7
复制代码if(!get_magic_quotes_gpc())
{
$_POST = deep_addslashes($_POST);
$_GET = deep_addslashes($_GET);
$_COOKIES = deep_addslashes($_COOKIES);
$_REQUEST = deep_addslashes($_REQUEST);
}

如果未开启magic_quotes_gpc,则对以各种请求的数据使用deep_addslashes()进行过滤,跟踪一下这个函数:

3-2-180x138.png

对传入的的$str,无论是数组还是字符串,使用addslashes()进行过滤。

PS:magic_quotes_gpc=On的情况下,如果输入的数据有,单引号(’)、双引号(”)、反斜线()与 NUL(NULL 字符)等字符都会被加上反斜线。这些转义是必须的,如果这个选项为off,那么我们就必须调用addslashes这个函数来为字符串增加转义。

0×02 注入分析

在phpstorm使用CTRL+SHIFT+F全局搜索:$_GET

寻找以GET方式传入的参数:

4-180x138.png

使用红框圈起来的这条有异常。

ad_id明显是文章或者广告的id,并没有使用intval强制转化为整数型而是使用了trim()函数来去除了前后的空格,有点看不懂。

此时还不能完全确定存在注入,跟踪到这行代码看一下:

5-180x138.png

这次确定,对传入的ad_id只判断了是否为空,去除了前后的空格,此外也就多了一个全局的addslashes()转义了一下特殊字符,直接进行了SQL查询。

将SQL语句传入了getone()函数,很明显getone是进行SQL查询的函数,跟进。

getone()函数:

1
2
3
4
5
复制代码function getone($sql, $type=MYSQL_ASSOC){
$query = $this->query($sql,$this->linkid);
$row = mysql_fetch_array($query, $type);
return $row;
}

query()函数:

1
2
3
4
5
6
7
复制代码function query($sql){
if(!$query=@mysql_query($sql, $this->linkid)){
$this->dbshow("Query error:$sql");
}else{
return $query;
}
}

第一个if,如果执行发生错误,将错误信息”Query errorsql”传入dbshow()函数。

dbshow()函数:

1
2
3
4
5
6
7
8
9
10
11
复制代码function dbshow($msg){
if($msg){
echo "Error:".$msg."

";
}else{
echo "Errno:".$this->errno()."
Error:".$this->error();
}
exit;
}

作用是输出错误信息。

之后回到ad_js.php文件:

6.png

$ad_content输出查询信息。

输出形式:

1
2
3
复制代码<!–
document.write("test");
–>

0×03 构造Payload

正常的SQL查询语句为:

1
复制代码select * from blue_ad where ad_id=1

因为直接回显查询内容,所以直接union注入咯。

看一下数据库结构:
7.png

我们需要的数据列名为admin_name和pwd,构造PayLoad:

执行后查看源码:

8-1.png

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

白话解析:一致性哈希算法 consistent hashin

发表于 2017-12-12

在了解一致性哈希算法之前,最好先了解一下缓存中的一个应用场景,了解了这个应用场景之后,再来理解一致性哈希算法,就容易多了,也更能体现出一致性哈希算法的优点,那么,我们先来描述一下这个经典的分布式缓存的应用场景。

场景描述

假设,我们有三台缓存服务器,用于缓存图片,我们为这三台缓存服务器编号为0号、1号、2号,现在,有3万张图片需要缓存,我们希望这些图片被均匀的缓存到这3台服务器上,以便它们能够分摊缓存的压力。也就是说,我们希望每台服务器能够缓存1万张左右的图片,那么,我们应该怎样做呢?如果我们没有任何规律的将3万张图片平均的缓存在3台服务器上,可以满足我们的要求吗?可以!但是如果这样做,当我们需要访问某个缓存项时,则需要遍历3台缓存服务器,从3万个缓存项中找到我们需要访问的缓存,遍历的过程效率太低,时间太长,当我们找到需要访问的缓存项时,时长可能是不能被接收的,也就失去了缓存的意义,缓存的目的就是提高速度,改善用户体验,减轻后端服务器压力,如果每次访问一个缓存项都需要遍历所有缓存服务器的所有缓存项,想想就觉得很累,那么,我们该怎么办呢?原始的做法是对缓存项的键进行哈希,将hash后的结果对缓存服务器的数量进行取模操作,通过取模后的结果,决定缓存项将会缓存在哪一台服务器上,这样说可能不太容易理解,我们举例说明,仍然以刚才描述的场景为例,假设我们使用图片名称作为访问图片的key,假设图片名称是不重复的,那么,我们可以使用如下公式,计算出图片应该存放在哪台服务器上。

hash(图片名称)% N

因为图片的名称是不重复的,所以,当我们对同一个图片名称做相同的哈希计算时,得出的结果应该是不变的,如果我们有3台服务器,使用哈希后的结果对3求余,那么余数一定是0、1或者2,没错,正好与我们之前的服务器编号相同,如果求余的结果为0, 我们就把当前图片名称对应的图片缓存在0号服务器上,如果余数为1,就把当前图片名对应的图片缓存在1号服务器上,如果余数为2,同理,那么,当我们访问任意一个图片的时候,只要再次对图片名称进行上述运算,即可得出对应的图片应该存放在哪一台缓存服务器上,我们只要在这一台服务器上查找图片即可,如果图片在对应的服务器上不存在,则证明对应的图片没有被缓存,也不用再去遍历其他缓存服务器了,通过这样的方法,即可将3万张图片随机的分布到3台缓存服务器上了,而且下次访问某张图片时,直接能够判断出该图片应该存在于哪台缓存服务器上,这样就能满足我们的需求了,我们暂时称上述算法为HASH算法或者取模算法,取模算法的过程可以用下图表示。

白话解析:一致性哈希算法 consistent hashing

但是,使用上述HASH算法进行缓存时,会出现一些缺陷,试想一下,如果3台缓存服务器已经不能满足我们的缓存需求,那么我们应该怎么做呢?没错,很简单,多增加两台缓存服务器不就行了,假设,我们增加了一台缓存服务器,那么缓存服务器的数量就由3台变成了4台,此时,如果仍然使用上述方法对同一张图片进行缓存,那么这张图片所在的服务器编号必定与原来3台服务器时所在的服务器编号不同,因为除数由3变为了4,被除数不变的情况下,余数肯定不同,这种情况带来的结果就是当服务器数量变动时,所有缓存的位置都要发生改变,换句话说,当服务器数量发生改变时,所有缓存在一定时间内是失效的,当应用无法从缓存中获取数据时,则会向后端服务器请求数据,同理,假设3台缓存中突然有一台缓存服务器出现了故障,无法进行缓存,那么我们则需要将故障机器移除,但是如果移除了一台缓存服务器,那么缓存服务器数量从3台变为2台,如果想要访问一张图片,这张图片的缓存位置必定会发生改变,以前缓存的图片也会失去缓存的作用与意义,由于大量缓存在同一时间失效,造成了缓存的雪崩,此时前端缓存已经无法起到承担部分压力的作用,后端服务器将会承受巨大的压力,整个系统很有可能被压垮,所以,我们应该想办法不让这种情况发生,但是由于上述HASH算法本身的缘故,使用取模法进行缓存时,这种情况是无法避免的,为了解决这些问题,一致性哈希算法诞生了。

我们来回顾一下使用上述算法会出现的问题。

问题1:当缓存服务器数量发生变化时,会引起缓存的雪崩,可能会引起整体系统压力过大而崩溃(大量缓存同一时间失效)。

问题2:当缓存服务器数量发生变化时,几乎所有缓存的位置都会发生改变,怎样才能尽量减少受影响的缓存呢?

其实,上面两个问题是一个问题,那么,一致性哈希算法能够解决上述问题吗?

我们现在就来了解一下一致性哈希算法。

一致性哈希算法的基本概念

其实,一致性哈希算法也是使用取模的方法,只是,刚才描述的取模法是对服务器的数量进行取模,而一致性哈希算法是对2^32取模,什么意思呢?我们慢慢聊。

首先,我们把二的三十二次方想象成一个圆,就像钟表一样,钟表的圆可以理解成由60个点组成的圆,而此处我们把这个圆想象成由2^32个点组成的圆,示意图如下:

白话解析:一致性哈希算法 consistent hashing

圆环的正上方的点代表0,0点右侧的第一个点代表1,以此类推,2、3、4、5、6……直到2^32-1,也就是说0点左侧的第一个点代表2^32-1

我们把这个由2的32次方个点组成的圆环称为hash环。

那么,一致性哈希算法与上图中的圆环有什么关系呢?我们继续聊,仍然以之前描述的场景为例,假设我们有3台缓存服务器,服务器A、服务器B、服务器C,那么,在生产环境中,这三台服务器肯定有自己的IP地址,我们使用它们各自的IP地址进行哈希计算,使用哈希后的结果对2^32取模,可以使用如下公式示意。

hash(服务器A的IP地址) % 2^32

通过上述公式算出的结果一定是一个0到2^32-1之间的一个整数,我们就用算出的这个整数,代表服务器A,既然这个整数肯定处于0到2^32-1之间,那么,上图中的hash环上必定有一个点与这个整数对应,而我们刚才已经说明,使用这个整数代表服务器A,那么,服务器A就可以映射到这个环上,用下图示意

白话解析:一致性哈希算法 consistent hashing

同理,服务器B与服务器C也可以通过相同的方法映射到上图中的hash环中

hash(服务器B的IP地址) % 2^32

hash(服务器C的IP地址) % 2^32

通过上述方法,可以将服务器B与服务器C映射到上图中的hash环上,示意图如下

白话解析:一致性哈希算法 consistent hashing

假设3台服务器映射到hash环上以后如上图所示(当然,这是理想的情况,我们慢慢聊)。

好了,到目前为止,我们已经把缓存服务器与hash环联系在了一起,我们通过上述方法,把缓存服务器映射到了hash环上,那么使用同样的方法,我们也可以将需要缓存的对象映射到hash环上。

假设,我们需要使用缓存服务器缓存图片,而且我们仍然使用图片的名称作为找到图片的key,那么我们使用如下公式可以将图片映射到上图中的hash环上。

hash(图片名称) % 2^32

映射后的示意图如下,下图中的橘黄色圆形表示图片

白话解析:一致性哈希算法 consistent hashing

好了,现在服务器与图片都被映射到了hash环上,那么上图中的这个图片到底应该被缓存到哪一台服务器上呢?上图中的图片将会被缓存到服务器A上,为什么呢?因为从图片的位置开始,沿顺时针方向遇到的第一个服务器就是A服务器,所以,上图中的图片将会被缓存到服务器A上,如下图所示。

白话解析:一致性哈希算法 consistent hashing

没错,一致性哈希算法就是通过这种方法,判断一个对象应该被缓存到哪台服务器上的,将缓存服务器与被缓存对象都映射到hash环上以后,从被缓存对象的位置出发,沿顺时针方向遇到的第一个服务器,就是当前对象将要缓存于的服务器,由于被缓存对象与服务器hash后的值是固定的,所以,在服务器不变的情况下,一张图片必定会被缓存到固定的服务器上,那么,当下次想要访问这张图片时,只要再次使用相同的算法进行计算,即可算出这个图片被缓存在哪个服务器上,直接去对应的服务器查找对应的图片即可。

刚才的示例只使用了一张图片进行演示,假设有四张图片需要缓存,示意图如下

白话解析:一致性哈希算法 consistent hashing

1号、2号图片将会被缓存到服务器A上,3号图片将会被缓存到服务器B上,4号图片将会被缓存到服务器C上。

一致性哈希算法的优点

经过上述描述,我想兄弟你应该已经明白了一致性哈希算法的原理了,但是话说回来,一致性哈希算法能够解决之前出现的问题吗,我们说过,如果简单的对服务器数量进行取模,那么当服务器数量发生变化时,会产生缓存的雪崩,从而很有可能导致系统崩溃,那么使用一致性哈希算法,能够避免这个问题吗?我们来模拟一遍,即可得到答案。

假设,服务器B出现了故障,我们现在需要将服务器B移除,那么,我们将上图中的服务器B从hash环上移除即可,移除服务器B以后示意图如下。

白话解析:一致性哈希算法 consistent hashing

在服务器B未移除时,图片3应该被缓存到服务器B中,可是当服务器B移除以后,按照之前描述的一致性哈希算法的规则,图片3应该被缓存到服务器C中,因为从图片3的位置出发,沿顺时针方向遇到的第一个缓存服务器节点就是服务器C,也就是说,如果服务器B出现故障被移除时,图片3的缓存位置会发生改变

白话解析:一致性哈希算法 consistent hashing

但是,图片4仍然会被缓存到服务器C中,图片1与图片2仍然会被缓存到服务器A中,这与服务器B移除之前并没有任何区别,这就是一致性哈希算法的优点,如果使用之前的hash算法,服务器数量发生改变时,所有服务器的所有缓存在同一时间失效了,而使用一致性哈希算法时,服务器的数量如果发生改变,并不是所有缓存都会失效,而是只有部分缓存会失效,前端的缓存仍然能分担整个系统的压力,而不至于所有压力都在同一时间集中到后端服务器上。

这就是一致性哈希算法所体现出的优点。

hash环的偏斜

在介绍一致性哈希的概念时,我们理想化的将3台服务器均匀的映射到了hash环上,如下图所示

白话解析:一致性哈希算法 consistent hashing

但是,理想很丰满,现实很骨感,我们想象的与实际情况往往不一样。

白话解析:一致性哈希算法 consistent hashing

在实际的映射中,服务器可能会被映射成如下模样。

白话解析:一致性哈希算法 consistent hashing

聪明如你一定想到了,如果服务器被映射成上图中的模样,那么被缓存的对象很有可能大部分集中缓存在某一台服务器上,如下图所示。

白话解析:一致性哈希算法 consistent hashing

上图中,1号、2号、3号、4号、6号图片均被缓存在了服务器A上,只有5号图片被缓存在了服务器B上,服务器C上甚至没有缓存任何图片,如果出现上图中的情况,A、B、C三台服务器并没有被合理的平均的充分利用,缓存分布的极度不均匀,而且,如果此时服务器A出现故障,那么失效缓存的数量也将达到最大值,在极端情况下,仍然有可能引起系统的崩溃,上图中的情况则被称之为hash环的偏斜,那么,我们应该怎样防止hash环的偏斜呢?一致性hash算法中使用”虚拟节点”解决了这个问题,我们继续聊。

虚拟节点

话接上文,由于我们只有3台服务器,当我们把服务器映射到hash环上的时候,很有可能出现hash环偏斜的情况,当hash环偏斜以后,缓存往往会极度不均衡的分布在各服务器上,聪明如你一定已经想到了,如果想要均衡的将缓存分布到3台服务器上,最好能让这3台服务器尽量多的、均匀的出现在hash环上,但是,真实的服务器资源只有3台,我们怎样凭空的让它们多起来呢,没错,就是凭空的让服务器节点多起来,既然没有多余的真正的物理服务器节点,我们就只能将现有的物理节点通过虚拟的方法复制出来,这些由实际节点虚拟复制而来的节点被称为”虚拟节点”。加入虚拟节点以后的hash环如下。

白话解析:一致性哈希算法 consistent hashing

“虚拟节点”是”实际节点”(实际的物理服务器)在hash环上的复制品,一个实际节点可以对应多个虚拟节点。

从上图可以看出,A、B、C三台服务器分别虚拟出了一个虚拟节点,当然,如果你需要,也可以虚拟出更多的虚拟节点。引入虚拟节点的概念后,缓存的分布就均衡多了,上图中,1号、3号图片被缓存在服务器A中,5号、4号图片被缓存在服务器B中,6号、2号图片被缓存在服务器C中,如果你还不放心,可以虚拟出更多的虚拟节点,以便减小hash环偏斜所带来的影响,虚拟节点越多,hash环上的节点就越多,缓存被均匀分布的概率就越大。

好了,一致性哈希算法的原理就总结到这里,如有错误,欢迎赐教,如需转载,请联系作者。

原文链接:白话解析:一致性哈希算法 consistent hashing

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

用zookeeper来构建的一种一致性副本协议

发表于 2017-12-12

说明

我曾经在研究生期间负责开发过一个对可用性有要求的服务。为了保障该服务的可用性,我基于zookeeper设计了一个副本复制的解决方法,以确保当单个服务节点出现故障后,其他的备用服务节点能够被选为主用服务节点,并对外提供服务,以保障整个系统不受单点故障的影响。与此同时,还能保障系统的数据一致性。本文介绍的内容就是这种解决方案的总结和抽象。

背景

在一个分布式系统中,多个有状态的服务节点协同工作,完成某项系统功能。对于服务节点来说,保障其无故障运行,或者当其出现故障时,能够快速恢复,是一件很有挑战的事情。同时,带有状态的服务节点在快速恢复时,还需要恢复到故障出现前的服务状态,更加地加大了系统设计的难度。

1. CAP定理

由Eric Brewer在2000年提出的CAP定理[1],提出了在一个服务中,无法同时满足数据一致性,服务可用性和分区容错性。分区容错性不仅仅包含网络分区,还应该包括宕机等异常情形。由于在分布式系统中,分区容错性是必须被满足的,因此分布式系统只能在数据一致性和服务可用性中做出选择。

可用性指的是在足够长的时间内,一个服务可用的时间。因此为了提高可用性,需要提高系统的可靠性,也就是系统连续无故障运行的时间,和需要减少系统在出现故障后的恢复时间。系统的可靠性与系统本身的实现与部署有关,不在本文讨论的范围。本文的设计,主要关注的是系统故障后的恢复时间。

对于数据一致性,保障的是后续操作对于先前操作的可见性。如果后续的读取,无法读到先前写入的数据,会使得基于此系统的开发变得困难。

2. 多副本容灾

为了能够达成故障恢复的目标,传统的做法是基于主用服务器与备用服务器之间做同步或者异步数据复制,也就是primary-secondary协议[2]。当主用服务故障后,可以快速切换到备用服务。如果使用同步的数据复制,可以保障数据一致性,但是没办法保障系统可用性。因为无论主用服务还是备用服务出现了故障,都会导致服务不可用。因为必须将宕机服务重新启动后才能恢复服务,从而导致系统故障恢复时间变长。如果使用异步的数据复制,如果主用服务节点出现故障,可以很快切换到备用正常工作,从而缩短了故障恢复时间,因而提高了系统的可用性。但是有可能会出现数据不一致的情况,例如,用户在之前写入的数据,在后续的读取中无法被读到。

基于paxos[3]协议和raft[4]协议的系统是多副本容灾中最常用的解决方案。因为paxos协议或者raft协议能够保障数据一致性,并同时最大限度地保障系统可用性,只有当副本节点出现一半或以上的宕机情况时,才会影响可用性。否则,系统都能够在短时间内恢复回来,并拥有一致性的数据副本。但是由于在系统中嵌入地正确实现无论是paxos协议,还是简化的raft协议,都是相当有挑战的事情。为了简化上述系统的实现,我们可以借助像zookeeper[5]等高可用的分布式协调服务,来帮助我们完成选主,和节点状态监听等工作。从而在此基础上,完成日志复制等工作,进而大大地简化这个系统的实现。因此在这个系统设计中,我会使用zookeeper来完成选主和节点监听等工作。

3. 设计考虑

无论是在paxos还是raft中,系统保障的是CAP中的数据一致性和分区容错性,也就是CP。因为只要出现一半或者以上数量的副本节点宕机的情况,就会影响系统的可用性,因此paxos协议或者raft协议都不能保障完美可用性。本文设计的系统依然是保证了CAP中的数据一致性和分区容错性,但是为了简化实现,并没有采用paxos或者raft的方案。而是借鉴了了primary-secondary协议的做法,在主用节点和备用节点之间做同步日志复制。但同时引入了zookeeper来监听节点的存活状态,从而缩短了系统恢复可用的时间,提高了可用性。

因为本文设计的系统保障了数据一致性,牺牲了系统的部分性能和可用性。但是这种选择是值得的,保障了数据一致性的系统,可以屏蔽数据不一致给业务层带来的烦恼,从而降低业务开发的工作难度。

相关工作

1. 复制状态机

在本文介绍的系统中,需要把服务节点抽象成一个状态机[6]。每个节点包含一组状态,一个转换函数和一个输出函数。客户发往服务节点的请求都可以抽象为一个操作日志,作为转换函数和输出函数的输入。多个相同初始状态的状态机,输入相同的操作日志序列,最终能够得到相同的状态,并且输出相同的结果。因此,系统只需要在多个副本节点中同步复制操作日志流,即可实现系统的状态复制。

2. 选主实现

在多个复制状态机,也即服务节点中,需要选举出一个主用服务节点,来对外提供读写服务。为了简化实现,本系统使用了zookeeper中的分布式锁服务来实现选主功能。多个服务节点在启动后都会向zookeeper中的同一目录下,去请求创建同一个临时锁文件。只有第一个服务节点能够创建成功,接着成为主用服务节点。其他服务节点成为备用服务节点,并去监听临时锁文件的状态。当主用服务节点发生故障,导致主用服务节点与zookeeper的租约到期,临时锁文件会被zookeeper删除,然后会通知其他的备用服务节点。备用服务节点接着可以再次请求创建临时锁文件,进而成为新的主用服务节点。

3. 节点存活状态监听

每一个节点都会在zookeeper上创建一个临时文件,并协商最大租约时间。当节点出现故障,租约到期后,临时文件会被删除,并向所有节点广播该节点的故障信息。当一个故障的节点恢复后,会重新到zookeeper上去创建临时文件,zookeeper会向其他节点广播该节点重新上线的消息。以上机制可以确保每一个节点都拥有了当前所有节点的存活状态。

4. 日志复制

主用服务节点在接受客户的操作日志流时,需要把日志流复制到备用服务节点上。每条日志都带有唯一自增序号。

对于每一条操作日志,主用服务节点会将操作日志顺序写入磁盘,确保操作日志的持久化,同时将操作日志发往所有的备用服务节点。所有的备用服务节点在接受到操作日志后,同样要把操作日志顺序写入磁盘,然后向主用服务节点返回确认。主用服务节点只要等到当前操作日志已经被写入本机磁盘,以及已经接收到除自己之外,所有的存活的备用节点的确认消息,就可以认为当前操作日志同步完成,可以去处理下一条操作日志。

在处理返回客户端当前操作日志处理完成,并去处理下一条日志之前,需要在zookeeper上记录当前最新确认成功提交的日志序号。在zookeeper上记录最新commit的日志序号,zookeeper会将最新commit的序号广播给所有节点,节点上就可以提交给状态机了,主用节点上还要返回客户端。最新commit日志序号,记录在zookeeper上,还可以用于以供后续节点宕机恢复以及新节点加入时使用。新恢复或启动的节点,只需到zookeeper上查询最新commit的日志序号,就可以向其他节点请求自己所缺失的日志了。

5. 故障恢复

当节点出现故障时,主要分这两种类型。

首先是主用节点出现故障。此时,新的主用节点会被选出来。由于主用节点和备用节点之间采用了同步的日志复制方式,所以备用节点可以快速地成为主用,而不用去其他节点上拉取未同步的日志。

接着,如果是备用节点出现故障。此时,主用节点的日志流同步复制操作会出现阻塞。当备用节点与zookeeper的租约到期后,备用节点故障的消息会被zookeeper广播到主用节点上。此时,主用节点的日志流同步工作可以继续下去。

当一个节点从故障中恢复回来,或者加入一个新节点。此时,该节点状态会落后于其他节点,该节点会向zookeeper获取最新确认成功提交的日志序号,然后向其他节点拉取缺失的操作日志。在该节点完成日志同步之前,无法应答其他节点和客户端的任何请求。

因此,该系统中,出现机器宕机和机器恢复时,都会导致系统短暂的不可用,无法处理操作日志,从而无法应答客户端的写入请求。但是,仍然能够应答客户端的读取请求。

6. 日志提交

日志在复制到所有的存活节点上后,最后需要确认提交状态。在很多一致性复制算法中,会将日志复制和提交的流程分离开。首先主用节点发起日志复制,复制成功后,再发起日志提交流程。当收到提交请求后,状态机就可以执行日志了。在本文设计的系统中,当完成日志复制后,主用节点会将提交日志id更新到zookeeper,通过zookeeper来广播其他节点操作日志的提交消息。通过zookeeper来分发和记录最新提交日志的id,虽然在系统性能上会有部分损耗,但是却能极大的简化系统的实现。

当一个新的节点加入复制组,或者一个之前故障的节点恢复后,提交日志都需要恢复到和zookeeper上的commit日志序号一样的位置,才能正常对外提供服务。因此,在新加入节点,或者节点恢复时,会导致系统暂时不可用,不可用的时间长短与节点日志恢复的时间有关。

当日志完成提交后,状态机就可以执行操作日志了,这里的状态机与具体的应用有关,不属于本文要讨论的范围。

系统设计

1. 总体架构设计

该系统的总体架构中,需要有zookeeper集群,作为分布式协调服务,还有多个节点。其中有一个主节点,与多个备用节点。主用节点向备用节点同步日志。

架构图如下:

2. 系统接口

系统向用户暴露日志提交接口,并提供同步或者异步的提交确认。

接口名称 参数 返回值 说明
SubmitLogSync log 操作日志, timeout 超时时间 state 提交状态 操作日志同步提交的接口,成功提交操作日志后返回成功。可以存在三种返回状态:成功,失败,和超时
SubmitLogAsync log 操作日志, timeout 超时时间, callback((state) -> {}) 回调函数 无 操作日志异步提交的接口,成功提交操作日志后,调用回调函数,返回成功。回调函数中同样可能存在三种状态:成功,失败和超时

通过向用户暴露日志提交接口,用户可以通过客户端,向系统提交操作日志,系统在完成日志在多节点的复制后,会提交给状态机来执行。

3. 与Zookeeper交互的模块设计

Zookeeper在本文设计的系统中主要负责三个功能,选主,节点存活状态监听和记录最新一次成功提交的日志序号。

  • Zookeeper上目录结构设计

因此在zookeeper上的目录结构如下所示:

文件和目录位置 作用 属性 监听节点 说明
/lock 用于选主,文件中保存主用的位置 临时文件 所有备用节点 创建这个文件的节点成为主用节点
/nodes/[ip:port] 记录每个节点的存活状态 临时文件 无 [ip:port]为节点接受请求的位置
/logid 记录最新一次成功提交的日志序号 普通文件 所有节点 每次更新都会广播给所有节点
/nodes 用于广播节点的实时存活状态 普通文件 所有节点 所有节点需要关注这个目录下节点的状态变化
  • 节点存活状态监听

在每个节点启动后,都会去zookeeper的/nodes目录下面,创建一个临时文件,文件名称为本节点的地址,并在zookeeper上注册监听/nodes目录下面子节点的变化。

当有节点出现故障后,与zookeeper的心跳中断,租约到期。之后zookeeper会删除该故障节点创建的临时文件,并通知其他复制组节点,关于/nodes目录下的子节点变化。其他节点就可以实时知道当前所有节点的存活情况。

  • 选主

在节点注册完自身状态后,还需要去创建/lock的临时文件,如果创建成功,则成为主用节点。如果文件已存在,说明已经存在其他主用,则只需要监听/lock文件状态,并成为备用节点。当主用出现故障后,与zookeeper的租约也会到期,文件也会被删除,并通知备用节点。备用节点接收到/lock文件被删除的通知后,可以再次去创建/lock文件。

  • 记录提交日志序号

在完成每一条日志的复制后,主节点会去zookeeper上更新最新的成功提交日志序号。Zookeeper会把日志序号变化的事件,广播给所有节点。

4. RPC设计

整个系统的实现,需要每一个节点上都实现以下功能的RPC。

  • 处理主节点发送的日志

主节点会将客户端发送过来的操作日志,发往其他的备用节点。备用节点在接收到操作日志后,会将日志写入磁盘,并返回确认消息。

方法名 参数1 参数2 返回值
HandleOpLog logId log ack
处理主节点发送的日志 接收到的日志的id号 接收到的日志 返回确认
  • 处理日志拉取请求

在新节点,或者先前出现故障的节点恢复后,需要向其他节点拉取日志,同步到与其他节点一致的日志状态,然后才能正常对外服务。所以,这里同样提供了一个日志拉取的RPC调用。

方法名 参数1 参数2 返回值
PullLog startId endId logs
处理日志拉取请求 拉取的起始日志id 拉取的最后日志id 返回日志

功能实现

1. 节点上线

当节点上线后,首先在zookeeper的/nodes目录中创建一个临时文件。然后尝试在zookeeper上创建/lock临时文件,如果创建成功,则成为主用节点,否则,成为备用节点。

接下来,执行状态恢复的流程。读取zookeeper上/logid文件内的最新提交的日志序号,并读取本节点的最新提交的日志序号。比较两个日志序号,如果本节点已提交日志落后于其他节点,则调用PullLog的RPC,完成日志拉取并恢复。在日志恢复完成之前,无法响应客户端和其他节点的RPC请求。

完成日志恢复后,可以正常响应客户端和其他节点的RPC请求。

处理流程如下:

2. 主节点处理操作日志

当主节点接受到客户端的操作日志,首先写入磁盘,然后将日志通过HandleOpLog的RPC,发往备用节点,等待备用节点RPC都返回确认后,则认为该日志复制成功,修改zookeeper上/logid文件内容为该日志序号。如果发往某个备用节点的HandleOpLog的RPC调用失败,则不断重试,直到成功,或者该备用节点被zookeeper检测到出现故障。

处理流程如下:

3. 备用节点处理操作日志

当备用节点通过PRC HandleOpLog接收到主节点发送过来的操作日志后,首先需要写入磁盘,等待日志持久化成功后,再返回成功确认。

4. 状态机执行

当主用节点更新/logid 中的最新提交日志序号后,zookeeper会将日志序号广播给所有的节点,然后节点就可以推进状态机的执行日志。

5. 选主实现

在本系统中,选主是依赖于zookeeper来实现的,通过多个节点抢占创建zookeeper上的/lock文件。当主节点出现故障后,/lock文件会被zookeeper删除,然后广播通知其他备用节点。接收到通知的节点,可以再次抢占创建/lock文件。

优化

1. 日志批量操作

在主节点接受到客户端的操作日志的时候,先不急着写入磁盘和发往备用节点。可以缓存一段时间的操作日志,然后再一起写入磁盘和发往备用节点,这样可以提高系统的吞吐量。但是主节点由于需要缓存日志,所以系统的响应延迟会增长。所以系统要根据业务场景来选择缓存时间。

2. 日志流水线操作

主节点不需要等到之前的操作日志都成功写入磁盘和复制到备用节点上后,才能发起新操作日志的复制操作。而是可以并行发起多条日志的写入磁盘和复制到备用节点的操作,从而某条日志的复制阻塞,不会影响到后面日志的复制操作。不过,主用节点在zookeeper上更新已提交日志序号的操作,日志序号必须是增长的。而且,状态机执行操作日志也是顺序执行。

3. 日志拉取

如果刚加入的节点,日志落后其他节点太多,可以通过生成snapshot文件,新节点拉取snapshot,来加快日志和状态的恢复。

4. 数据读取

由于在这个系统中,各个节点的状态是强一致的,所以数据读取可以在系统的任意一个节点上执行,从而降低主节点的系统负荷。所以本文介绍的是一个单写多读的系统,通过增长节点个数,可以优化数据读取性能,不过会导致写入变慢,因为日志同步的代价会加大。

如果业务中对读取到的数据不要求强一致,允许一定的延时,可以考虑加入异步复制的弱一致节点。弱一致节点不参与主节点的选举和同步的日志复制,只会异步的向普通节点拉取日志,并对外提供弱一致性读的功能。

讨论

1. 与客户端交互

客户端在向主节点提交操作日志后,主节点需要向客户端返回确认。返回确认的时间应该是在将日志序号更新到zookeeper的/logid中后,因为此时就可以确认日志已提交。

如果系统由于异常,比如网络中断,机器宕机等,导致客户端超时未得到日志提交结果。此时,进入未知状态,客户端需要先向系统读取日志提交情况,根据情况来决定是否重新执行日志提交。

2. 与paxos和raft的比较

在本系统中,只要有一台机器出现故障,就会导致系统的不可用,不可用的时间与zookeeper的租约时长有关。但是在paxos中,单台机器的故障不会导致系统的不可用。如果是带有leader的paxos实现,如果leader所在的机器出现故障,新选出的leader需要执行两阶段流程恢复日志状态,恢复时间与新leader恢复日志的时间有关。所以paxos上如果leader所在节点出现故障,才会导致系统不可用,不可用时间与选主和日志恢复时间有关。在raft中,leader会由日志最长的机器担任,所以raft不存在日志恢复流程。所以raft系统同样只受leader故障的影响,不可用时间与选主时间有关。

在有节点加入的时候,本系统同样会出现不可用的情况,不可用时间与日志恢复的时间有关。但在paxos和raft的系统中不会有这种问题。

但是在paxos和raft中,只要出现一半或者一半以上的节点故障,系统将没办法自动恢复,需要人工介入。但是在本系统中只有所有节点出现故障,才会导致系统没法自动恢复。人工介入系统恢复会导致不可用时间延长,从这个角度来说,本文介绍的系统可用性要好于paxos和raft。

3. 适用场景

本文介绍的系统由于日志需要同步复制到所有的节点,因此不适用于部署在网络故障率高,网络延迟高的广域网场景,仅仅适用于数据中心内的局域网部署。

测试

1. 测试环境

测试环境由6台共有云上的主机组成,这几台主机处于同一个数据中心。其中,有三台用于部署zookeeper集群,有三台服务器用于部署本文设计的高可用系统集群。

2. 测试说明

本测试主要集中于当系统中出现节点宕机后,系统可用恢复所要的时间。而对于系统的整体读写性能,以及当新节点加入后的系统可用恢复,都与系统具体实现有关,不在本测试讨论的范围。

集群中有三台服务器,其中有一台主用节点,和两台备用节点。当备用节点故障后,主用节点无法向其复制日志,因此日志提交无法推进,此时系统处于不可用状态。接着当故障的备用节点与zookeeper的租约到期,然后zookeeper通知主用节点,此时主用节点更新存活节点列表,不会向该故障节点复制日志。此时系统故障恢复,进入可用状态。

当主用节点出现故障时,系统同样进入不可用状态。故障的主用节点与zookeeper的租约到期后,zookeeper会通知其他备用节点,主用失效的消息。备用节点首先要抢占创建zookeeper上的/lock文件,接着更新存活节点列表,最后系统的故障才可以恢复,进入可用状态。

主用节点调用HandleOpLog向备用节点复制日志的RPC超时时间设置为1s。

下述测试结果,与具体的程序实现有很大的关系,我根据研究生期间基于此设计开发的程序的测试结果,得出了下面的结果。

3. 测试结果

备用节点宕机恢复测试结果如下:

租约时间/s 系统不可用时间/s
5 6.8
10 12.1
20 22.5

主用节点宕机恢复测试结果如下:

租约时间/s 系统不可用时间/s
5 7.4
10 13.2
20 23.5

这里测试的不可用时间,根据的是主用节点和备用节点的日志数据估算而来,可能存在些误差,待后续再设计合理的测试场景来验证。

最后

本文是通过在高可用实践中的一点思考,从而设计出来的一种在保障数据一致性的条件下,解决系统高可用的设计思路。相比于paxos和raft协议,由于本设计需要依赖于分布式协调服务,因此在性能等方面不占优势。但是本设计依然有其自身的优点,比如实现简单,易于理解,从而能更容易地证明其实现的正确性。

本文提到的一致性副本协议,正在解耦出来,准备成为一个通用的多节点副本拷贝库,目前还在开发中。。。

参考文献

[1] Brewer, Eric A. “Towards robust distributed systems.” PODC. Vol. 7. 2000.
[2] 刘杰. 分布式系统原理介绍
[3] Lamport L. The part-time parliament. ACM Transactions on Computer Systems (TOCS). 1998
[4] Ongaro D, Ousterhout JK. In search of an
understandable consensus algorithm. InUSENIX Annual Technical Conference 2014
[5] Hunt P, Konar M, Junqueira FP, Reed B. ZooKeeper: Wait-free Coordination for Internet-scale Systems. InUSENIX annual technical conference 2010
[6] Schneider
FB. Implementing fault-tolerant services using the state machine approach: A tutorial. ACM Computing Surveys (CSUR). 1990

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

中小型研发团队架构实践:应用监控怎么做?

发表于 2017-12-12

一、Metrics 简介

应用监控系统 Metrics 由 Metrics.NET+InfluxDB+Grafana 组合而成,通过客户端 Metrics.NET 在业务代码中埋点,Metrics.NET 会把收集到的数据存储在 InfluxDB 数据库中,然后通过 Grafana 来展示监控数据。

其中,InfluxDB 服务端部署的版本号是 1.3.1,Grafana 部署的版本号是 4.0.1。下面将结合这 3 个工具来介绍如何实现对应用的监控。

Metrics.NET 移植自 Java 的 metrics,它是一个给 CLR 提供度量的工具包。在业务代码中埋点 Metrics.NET 代码后,就可以方便地对各技术指标、业务指标进行度量,如:共花多长时间完成某方法的执行、某方法在被执行的过程中共出现过几次异常、某时间段内共下多少订单量。

Metrics.NET 共提供 5 种度量类型:Gauge、Counter、Meter、Histogram 以及 Timer。其中 Meter 和 Histogram 这两种度量类型目前可以完全满足笔者所在公司的度量需求,所以,下面只介绍了 Meter 和 Histogram 这两种,另外 3 个若有兴趣可自行抽空去了解。

二、埋点 Metrics.NET 的方法

首先为需要收集 Metrics.NET 监控数据的业务项目引用 Metrics.dll。

然后,在项目中的 App.config/Web.config 文件中加上如下配置信息:

1
2
3
4
5
复制代码__Tue Dec 12 2017 10:06:00 GMT+0800 (CST)____Tue Dec 12 2017 10:06:00 GMT+0800 (CST)__<add key="AppID" value="150106"/>   
<add key="Metrics.DBUri" value="http://139.198.13.12:4126/write"/>
<add key="Metrics.UserName" value="Arch"/>
<add key="Metrics.Password" value="Arch"/>
<add key="Metrics.Database" value="ArchDB"/>__Tue Dec 12 2017 10:06:00 GMT+0800 (CST)____Tue Dec 12 2017 10:06:00 GMT+0800 (CST)__

1、Meter

Meter 用于度量 TPS(每秒处理的请求数)。

示例:模拟统计成功下单量、下单金额、失败下单量。

调用 Meter 对象的 Mark() 方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
复制代码__Tue Dec 12 2017 10:06:00 GMT+0800 (CST)____Tue Dec 12 2017 10:06:00 GMT+0800 (CST)__static void CreateOrder() 
{
try
{
// 省略关于下单的业务逻辑代码
//......

// 分别统计成功下单量和下单金额,统一写到 MetrisKey 中
MetricsKey.OrderCount.Mark();
if (n % 2 == 1)
{
MetricsKey.OrderMoneyCount.Mark("BuyerA", n);
}
else
{
MetricsKey.OrderMoneyCount.Mark("BuyerB", n);
}
}
catch (Exception)
{
// 统计失败下单量,统一写到 MetrisKey 中
MetricsKey.OrderErrorCount.Mark();

// 省略异常处理代码......
}
}__Tue Dec 12 2017 10:06:00 GMT+0800 (CST)____Tue Dec 12 2017 10:06:00 GMT+0800 (CST)__

2、Histogram

Histogram 用于度量流数据中 Value 的分布情况,它不仅使您能像 Meter 一样测量出 TPS ,还能测量出最小值、最大值和平均值。使用场景如:统计服务器的延迟时间、统计某方法共执行多长时间。

示例:模拟统计航班查询引擎方法的耗时情况。

调用 Histogram 对象的 Update() 方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
复制代码__Tue Dec 12 2017 10:06:00 GMT+0800 (CST)____Tue Dec 12 2017 10:06:00 GMT+0800 (CST)__private readonly Histogram searchFlightTime = MetricsHelper.Histogram("MetricsDemo.SearchFlightTime", Unit.Custom("ms"));

static void SearchFlight()
{
Stopwatch stopwatch = Stopwatch.StartNew();

// 模拟关于航班查询的业务逻辑的代码
Random random = new Random((int)DateTime.Now.Ticks & 0x0000FFFF);
var n = Random.Next(100);
Thread.Sleep(n);

stopwatch.Stop();

// 统计航班搜索耗时
searchFlightTime.Update(stopwatch.ElapsedMilliseconds);
}__Tue Dec 12 2017 10:06:00 GMT+0800 (CST)____Tue Dec 12 2017 10:06:00 GMT+0800 (CST)__

三、Grafana 配置

查阅 Metrics Dashboard Demo 的地址:http://139.198.13.12:4127/。打开这个 Metrics 地址后,如果页面显示已登录状态,那么在开始查阅前,请先确认是否把组织切换到了 Default Org.:

1、仪表盘设置

点击位于下图上方的 Home 图标,会下拉弹出 Dashboard 列表:

点击位于上图下方的 Create New 按钮,会进入到新建面板 Panel 页面,点击位于下图上方的保存图标按钮:

在弹出的 Save As… 对话框中输入 Dashboard 名称,如 Arch.OrderCountDemo,然后点击 Save 按钮进行保存:

2、面板(Panel)设置

点击上面创建的【Arch.OrderCountDemo】Dashboard 图标,进入属于这个 Dashboard 的面板(Panel)页面:

点击位于上图的 ADD ROW 按钮,进入下图。其中,Graph 表示以图表(有折线图、柱状图、散点图、梯形图)形式展示数据、Singlestat 表示单个统计、Table 表示以表格形式展示数据、PieChart 表示以饼状图形式展示数据。这几种统计类型的面板设置方式类似,本文将以 Graph 为例进行说明。

2.1、数据设置

点击上图的 Graph 图标创建图表:

点击上图的 Panel Title,在弹出菜单中单击 Edit 打开 Panel 编辑界面,即进入 Metrics 选项卡面板。关于 Meter 的查询数据语句配置一般如下:

关于 Histogram 的查询数据语句配置一般如下:

其中,fill() 一般被设为 null,但当查询时间范围很大时(如 1 天),请用 fill(0);另外,$appId、$serverIP、$summarize 这 3 个变量是在模板(Templating)中设置,请看第 3 小节的介绍。

2.2、样式配置

2.2.1、General 选项卡用来设置 Panel 样式

主要用来设置 Panel 的标题:

2.2.2、Axes 选项卡用来设置坐标轴

Label 表示设置左侧 Y 轴旁显示什么说明文字,另外,Unit 表示设置左侧 Y 轴数字的单位:

2.2.3、Legend 选项卡用来设置显示样式

2.2.4、Display 选项卡用来设置图表样式

Draw options 子选项卡用来设置图表显示效果:

  • Draw Modes:Points 表示设置是否在图中显示散点;
  • Mode Options:Fill 表示设置填充度、Line Width 表示设置图表线的粗细、Point Radius 表示设置圆点半径的长度;
  • Stacking & Null value:Null value 选择 null as zero 时表示设置当该时间节点在 InfluxDB 中没有记录时,用 0 替代。

3、模板(Templating)设置

打开 Templating 设置页面:

新建变量:

新建 serverIP 变量:

(在 Query 文本框处,输的是:SHOW TAG VALUES WITH KEY = “ServerIP”)

新建 summarize 变量,其中 Values 值可以自行添加或删除,值与值之间用英文状态的逗号隔开:

新建 adhoc 变量:

4、设置 Time Range

一个 Dashboard 中,除了需要显示实时监控数据外,有时还需要显示历史的监控数据,主要目的是要通过对历史监控数据的观察来预测未来的业务量走势,那么需要重写 Time Range,即需要在 Time range 选项卡中进行设置。

例如,在一个 Panel 中需要显示近 24 小时的历史监控数据,那么请在这个 Panel 中加上如下配置:

5、告警设置

在 Grafana 当前版本(4.0.1)中,告警目前仅支持 Graph 类型的面板,在将来版本会添加 Singlestat 和 Table 类型面板的支持。另外,由于告警查询语句不支持 template 变量,所以最好是对不使用 template 变量的 Panel 才设置告警。

5.1、设置通知规则

在左侧菜单中选择 Alerting -> Notifications 进入通知列表页:

点击 New Notification 按钮新建一个通知:

在 Name 文本输入框中,输入通知名称,类型 Type 选择 email。设置完成之后单击 Save 按钮,然后点击 Send Test 按钮测试下通知是否能够发送成功。

5.2、设置告警规则

进入需要添加告警的 Panel 的编辑界面,转到 Alert 选项卡,点击 Create Alert 按钮,进入 Alert Config 子选项卡界面进行配置,其中 Evaluate every 表示设置执行频率,Conditions 表示配置何时告警的条件(WHEN 是选择聚合函数的地方,OF 用来设置时间段,IS ABOVE 或者 IS BELOW 用来设置阈值)。对 Alert Config 子选项卡界面的配置参考如下:

然后在 Notifications 子选项卡界面中配置通知规则:

5.3、暂停告警操作

在左侧菜单中点击 Alerting -> Alert List 进入告警规则列表页,点击暂停图标按钮就可以停止该告警:

四、其它说明

  • 1、Grafana 匿名访问地址: http://139.198.13.12:4127/。建议使用 Google Chrome 浏览器打开 Grafana;
  • 2、一个 MetricsName 对应一张数据表,建议明确定义 MetricsName;
  • 3、提供的 Metrics.dll 基于 0.4.8 的版本增加了 Unit Count 的返回,且适用于.NET Framework 4.5 及其以上版本。

五、总结 Metrics 的价值

  • 1、可以实时监控线上程序运行情况,形成闭环、不断改进;
  • 2、可以预测程序未来大致走向;
  • 3、可以及时发现故障,消灭在用户反馈之前;
  • 4、Metrics.NET 出现异常不影响业务流程;
  • 5、可设置自动报警,即时发送邮件、短信、微信 (通过 API)。

六、Demo 下载及更多资料

  • MetricsDemo 下载地址:github.com/das2017/Met…
  • Metrics.NET 官方网站:github.com/Recognos/Me…
  • InfluxDB 官方文档:docs.influxdata.com/influxdb/v1…
  • Grafana 官方文档:docs.grafana.org/

本系列文章涉及内容清单如下(并不按这顺序发布),其中有感兴趣的,欢迎关注:

  • 开篇:中小型研发团队架构实践三要点
  • 缓存 Redis
  • 消息队列 RabbitMQ:如何用好消息队列RabbitMQ?
  • 集中式日志 ELK
  • 任务调度 Job
  • 应用监控 Metrics:应用监控怎么做?
  • 微服务框架 MSA
  • 搜索利器 Solr
  • 分布式协调器 ZooKeeper
  • 小工具:
  • Dapper.NET/EmitMapper/AutoMapper/Autofac/NuGet
  • 发布工具 Jenkins
  • 总体架构设计:电商如何做企业总体架构?
  • 单个项目架构设计
  • 统一应用分层:如何规范公司所有应用分层?
  • 调试工具 WinDbg
  • 单点登录
  • 企业支付网关
  • 结篇

作者介绍

张辉清,10 多年的 IT 老兵,先后担任携程架构师、古大集团首席架构、中青易游 CTO 等职务,主导过两家公司的技术架构升级改造工作。现关注架构与工程效率,技术与业务的匹配与融合,技术价值与创新。

杨丽,拥有多年互联网应用系统研发经验,曾就职于古大集团,现任职中青易游的系统架构师,主要负责公司研发中心业务系统的架构设计以及新技术积累和培训。现阶段主要关注开源软件、软件架构、微服务以及大数据。

感谢雨多田光对本文的审校。

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

Python两个对象相等的原理

发表于 2017-12-12

概述

  大部分的python程序员平时编程的时候,很少关心两个对象为什么相等,因为教程和经验来说,他们就应该相等,比如1==1就应该返回True,可是当我们想要定义自己的对象或者修改默认的对象行为时,通常会因为不了解原理而导致各种奇奇怪怪的错误。

两个对象如何相等

  两个对象如何才能相等要比我们想象的复杂很多,但核心的方法是重写__eq__方法,这个方法返回True,则表示两个对象相等,否则,就不相等。相反的,如果两个对象不相等,则重写__ne__方法。   默认情况下,如果你没有实现这个方法,则使用父类(object)的方法。父类的方法比较是的两个对象的ID(可以通过id方法获取对象ID),也就是说,如果对象的ID相等,则两个对象也就相等。因此,我们可以得知,默认情况下,对象只和自己相等。例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
复制代码>>> class A(object):
... pass
...
>>>
>>> a = A()
>>> b = A()
>>> a == a
True
>>> a == b
False
>>> id(a)
4343310992
>>> id(b)
4343310928

  Python2程序员经常犯的一个错误是,只重写了__eq__方法,而没有重写__ne__方法,导致不可预计的错误。而Python3会自动重写__ne__方法,如果你没有重写的话。

对象的Hash方法

  Python里可Hash的对象,都有一个数字ID代表了它在python里的值,这个ID是由对象的__hash__方法返回的。因此,如果想让一个对象可Hash,那必须实现__hash__方法和之前提到的__eq__方法。和对象相等一样,默认情况下,对象的__hash__方法继承自Object对象,而Object对象的__hash__方法只计算对象ID,因此两个对象始终拥有两个不一样的hash id,不管他们是多么相似。   当我们把一个不可Hash的对象加入到set或者dict时,会发生什么了?

1
2
3
4
5
复制代码>>> set().add({})
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
TypeError: unhashable type: 'dict'
unhashable type: 'dict'

原因是set()和dict()使用对象的hash值作为内部索引,以便能快速索引到指定对象。因此,同一个对象返回相同的hash id就很重要了。

对象的Hash值在它的生命周期内不能改变

  如果你想定义一个比较完美的对象,并且实现了__eq__和__hash__方法来定义对象的比较行为和hash值,那么你就需要保证对象的相关属性不能发生更改。不然会导致很诡异的错误,比如下面的例子。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
复制代码>>> class C:
... def __init__(self, x):
... self.x = x
... def __repr__(self):
... return "C({"+str(self.x)+"})"
... def __hash__(self):
... return hash(self.x)
... def __eq__(self, other):
... return (
... self.__class__ == other.__class__ and
... self.x == other.x
... )
>>> d = dict()
>>> s = set()
>>> c = C(1)
>>> d[c] = 42
>>> s.add(c)
>>> d, s
({C(1): 42}, {C(1)})
>>> c in s and c in d # c is in both!
True
>>> c.x = 2
>>> c in s or c in d # c is in neither!?
False
>>> d, s
({C(2): 42}, {C(2)}) # but...it's right there!

在我们没有修改对象的属性时(c.x=2)之前,所有行为都符合预期。当我们通过c.x=2时修改属性后,执行c in s or c in d返回False,但是内容却是修改后的,是不是很奇怪。这也就解释了为什么str、tuple是可Hash的,而list和dict是不可hash的。

因此我们可以得出结论,如果两个对象相等的话,那它们的hash值必然也是相等的。

总结

讲了这么多有什么用了。 1. 当我们遇到unhashable type这个异常时,我们能够知道为什么报这个错误。 2. 如果定义了一个可比较的对象,那么最好保证对象hash值相关的属性在生命周期内不能发生改变,不然会发生意想不到的错误。

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

谈元编程与表达能力

发表于 2017-12-12
  • 元编程
  • 编译时和运行时
  • 宏(Macro)
  • 运行时(Runtime)
  • 总结
  • Reference

在这篇文章中,作者会介绍不同的编程语言如何增强自身的表达能力,在写这篇文章的时候其实就已经想到这可能不是一篇有着较多受众和读者的文章。不过作者仍然想跟各位读者分享一下对不同编程语言的理解,同时也对自己的知识体系进行简单的总结。

metaprogramming

当我们刚刚开始学习和了解编程这门手艺或者说技巧时,一切的知识与概念看起来都非常有趣,随着学习的深入和对语言的逐渐了解,我们可能会发现原来看起来无所不能的编程语言成为了我们的限制,尤其是在我们想要使用一些元编程技巧的时候,你会发现有时候语言限制了我们的能力,我们只能一遍一遍地写重复的代码来解决本可以轻松搞定的问题。

元编程

元编程(Metaprogramming)是计算机编程中一个非常重要、有趣的概念,维基百科 上将元编程描述成一种计算机程序可以将代码看待成数据的能力。

Metaprogramming is a programming technique in which computer programs have the ability to treat programs as their data.

如果能够将代码看做数据,那么代码就可以像数据一样在运行时被修改、更新和替换;元编程赋予了编程语言更加强大的表达能力,能够让我们将一些计算过程从运行时挪到编译时、通过编译期间的展开生成代码或者允许程序在运行时改变自身的行为。

metaprogramming-usage

总而言之,元编程其实是一种使用代码生成代码的方式,无论是编译期间生成代码,还是在运行时改变代码的行为都是『生成代码』的一种,下面的代码其实就可以看作一种最简单的元编程技巧:

1
2
3
4
5
6
7
8
cpp复制代码int main() {
for(int i = 0; i < 10; i++) {
char *echo = (char*)malloc(6 * sizeof(char));
sprintf(echo, "echo %d", i);
system(echo);
}
return 0;
}

这里的代码其实等价于执行了以下的 shell 脚本,也可以说这里使用了 C 语言的代码生成来生成 shell 脚本:

1
2
3
4
cli复制代码echo 0
echo 1
...
echo 9

编译时和运行时

现代的编程语言大都会为我们提供不同的元编程能力,从总体来看,根据『生成代码』的时机不同,我们将元编程能力分为两种类型,其中一种是编译期间的元编程,例如:宏和模板;另一种是运行期间的元编程,也就是运行时,它赋予了编程语言在运行期间修改行为的能力,当然也有一些特性既可以在编译期实现,也可以在运行期间实现。

compile-and-execute

不同的语言对于泛型就有不一样的实现,Java 的泛型就是在编译期间实现的,它的泛型其实是伪泛型,在编译期间所有的泛型就会被编译器擦除(type erasure),生成的 Java 字节码是不包含任何的泛型信息的,但是 C# 对于泛型就有着不同的实现了,它的泛型类型在运行时进行替换,为实例化的对象保留了泛型的类型信息。

C++ 的模板其实与这里讨论的泛型有些类似,它会为每一个具体类型生成一份独立的代码,而 Java 的泛型只会生成一份经过类型擦除后的代码,总而言之 C++ 的模板完全是在编译期间实现的,而 Java 的泛型是编译期间和运行期间协作产生的;模板和泛型虽然非常类似,但是在这里提到的模板大都特指 C++ 的模板,而泛型这一概念其实包含了 C++ 的模板。

虽然泛型和模板为各种编程语言提供了非常强大的表达能力,但是在这篇文章中,我们会介绍另外两种元编程能力:宏和运行时,前者是在编译期间完成的,而后者是在代码运行期间才发生的。

宏(Macro)

宏是很多编程语言具有的特性之一,它是一个将输入的字符串映射成其他字符串的过程,这个映射的过程也被我们称作宏展开。

macro-expansion

宏其实就是一个在编译期间中定义的展开过程,通过预先定义好的宏,我们可以使用少量的代码完成更多的逻辑和工作,能够减少应用程序中大量的重复代码。

很多编程语言,尤其是编译型语言都实现了宏这个特性,包括 C、Elixir 和 Rust,然而这些语言却使用了不同的方式来实现宏;我们在这里会介绍两种不同的宏,一种是基于文本替换的宏,另一种是基于语法的宏。

different-kinds-of-macros

C、C++ 等语言使用基于文本替换的宏,而类似于 Elixir、Rust 等语言的宏系统其实都是基于语法树和语法元素的,它的实现会比前者复杂很多,应用也更加广泛。

在这一节的剩余部分,我们会分别介绍 C、Elixir 和 Rust 三种不同的编程语言实现的宏系统,它们的使用方法、适用范围和优缺点。

C

作者相信很多工程师入门使用的编程语言其实都是 C 语言,而 C 语言的宏系统看起来还是相对比较简单的,虽然在实际使用时会遇到很多非常诡异的问题。C 语言的宏使用的就是文本替换的方式,所有的宏其实并不是通过编译器展开的,而是由预编译器来处理的。

preprocesso

编译器 GCC 根据『长相』将 C 语言中的宏分为两种,其中的一种宏与编程语言中定义变量非常类似:

1
2
3
4
cpp复制代码#define BUFFER_SIZE 1024

char *foo = (char *)malloc(BUFFER_SIZE);
char *foo = (char *)malloc(1024);

这些宏的定义就是一个简单的标识符,它们会在预编译的阶段被预编译器替换成定义后半部分出现的字符,这种宏定义其实比较类似于变量的声明,我们经常会使用这种宏定义替代一些无意义的数字,能够让程序变得更容易理解。

另一种宏定义就比较像对函数的定义了,与其他 C 语言的函数一样,这种宏在定义时也会包含一些宏的参数:

1
2
css复制代码#define plus(a, b) a + b
#define multiply(a, b) a * b

通过在宏的定义中引入参数,宏定义的内部就可以直接使用对应的标识符引入外界传入的参数,在定义之后我们就可以像使用函数一样使用它们:

1
2
3
4
5
6
7
8
cpp复制代码#define plus(a, b) a + b
#define multiply(a, b) a * b

int main(int argc, const char * argv[]) {
printf("%d", plus(1, 2)); // => 3
printf("%d", multiply(3, 2)); // => 6
return 0;
}

上面使用宏的代码与下面的代码是完全等价的,在预编译阶段之后,上面的代码就会被替换成下面的代码,也就是编译器其实是不负责宏展开的过程:

1
2
3
4
5
cpp复制代码int main(int argc, const char * argv[]) {
printf("%d", 1 + 2); // => 3
printf("%d", 3 * 2); // => 6
return 0;
}

宏的作用其实非常强大,基于文本替换的宏能做到很多函数无法做到的事情,比如使用宏根据传入的参数创建类并声明新的方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
clean复制代码#define pickerify(KLASS, PROPERTY) interface \
KLASS (Night_ ## PROPERTY ## _Picker) \
@property (nonatomic, copy, setter = dk_set ## PROPERTY ## Picker:) DKColorPicker dk_ ## PROPERTY ## Picker; \
@end \
@implementation \
KLASS (Night_ ## PROPERTY ## _Picker) \
- (DKColorPicker)dk_ ## PROPERTY ## Picker { \
return objc_getAssociatedObject(self, @selector(dk_ ## PROPERTY ## Picker)); \
} \
- (void)dk_set ## PROPERTY ## Picker:(DKColorPicker)picker { \
objc_setAssociatedObject(self, @selector(dk_ ## PROPERTY ## Picker), picker, OBJC_ASSOCIATION_COPY_NONATOMIC); \
[self setValue:picker(self.dk_manager.themeVersion) forKeyPath:@keypath(self, PROPERTY)];\
NSMutableDictionary *pickers = [self valueForKeyPath:@"pickers"];\
[pickers setValue:[picker copy] forKey:_DKSetterWithPROPERTYerty(@#PROPERTY)]; \
} \
@end

@pickerify(Button, backgroundColor);

上面的代码是我在一个 iOS 的开源库 DKNightVersion 中使用的代码,通过宏的文本替换功能,我们在这里创建了类、属性并且定义了属性的 getter/setter 方法,然而使用者对此其实是一无所知的。

C 语言中的宏只是提供了一些文本替换的功能再加上一些高级的 API,虽然它非常强大,但是强大的事物都是一把双刃剑,再加上 C 语言的宏从实现原理上就有一些无法避免的缺陷,所以在使用时还是要非常小心。

由于预处理器只是对宏进行替换,并没有做任何的语法检查,所以在宏出现问题时,编译器的报错往往会让我们摸不到头脑,不知道哪里出现了问题,还需要脑内对宏进行展开分析出现错误的原因;除此之外,类似于 multiply(1+2, 3) 的展开问题导致人和机器对于同一段代码的理解偏差,作者相信也广为人知了;更高级一些的分号吞噬、参数的重复调用以及递归引用时不会递归展开等问题其实在这里也不想多谈。

1
apache复制代码multiply(1+2, 3) // #=> 1+2 * 3

卫生宏

然而 C 语言宏的实现导致的另一个问题却是非常严重的:

1
2
3
4
5
6
7
8
9
cpp复制代码#define inc(i) do { int a = 0; ++i; } while(0)

int main(int argc, const char * argv[]) {
int a = 4, b = 8;
inc(a);
inc(b);
printf("%d, %d\n", a, b); // => 4, 9 !!
return 0;
}

这一小节与卫生宏有关的 C 语言代码取自 Hygienic macro 中的代码示例。

上述代码中的 printf 函数理应打印出 5, 9 然而却打印出了 4, 9,我们来将上述代码中使用宏的部分展开来看一下:

1
2
3
4
5
6
7
cpp复制代码int main(int argc, const char * argv[]) {
int a = 4, b = 8;
do { int a = 0; ++a; } while(0);
do { int a = 0; ++b; } while(0);
printf("%d, %d\n", a, b); // => 4, 9 !!
return 0;
}

这里的 a = 0 按照逻辑应该不发挥任何的作用,但是在这里却覆盖了上下文中 a 变量的值,导致父作用域中变量 a 的值并没有 +1,这其实就是因为 C 语言中实现的宏不是卫生宏(Hygiene macro)。

作者认为卫生宏(Hygiene macro)是一个非常让人困惑的翻译,它其实指一些在宏展开之后不会意外捕获上下文中标识符的宏,从定义中我们就可以看到 C 语言中的宏明显不是卫生宏,而接下来要介绍的两种语言的宏系统就实现了卫生宏。

Elixir

Elixir 是一门动态的函数式编程语言,它被设计用来构建可扩展、可维护的应用,所有的 Elixir 代码最终都会被编译成二进制文件运行在 Erlang 的虚拟机 Beam 上,构建在 Erlang 上的 Elixir 也继承了很多 Erlang 的优秀特性。然而在这篇文章中并不会展开介绍 Elixir 语言以及它的某些特点和应用,我们只想了解 Elixir 中的宏系统是如何使用和实现的。

elixir-logo

宏是 Elixir 具有强大表达能力的一个重要原因,通过内置的宏系统可以减少系统中非常多的重复代码,我们可以使用 defmacro 定义一个宏来实现 unless 关键字:

1
2
3
4
5
6
7
elixir复制代码defmodule Unless do
defmacro macro_unless(clause, do: expression) do
quote do
if(!unquote(clause), do: unquote(expression))
end
end
end

这里的 quote 和 unquote 是宏系统中最重要的两个函数,你可以从字面上理解 quote 其实就是在一段代码的两侧加上双引号,让这段代码变成字符串,而 unquote 会将传入的多个参数的文本原封不动的插入到相应的位置,你可以理解为 unquote 只是将 clause 和 expression 代表的字符串当做了返回值。

1
reasonml复制代码Unless.macro_unless true, do: IO.puts "this should never be printed"

上面的 Elixir 代码在真正执行之前会被替换成一个使用 if 的表达式,我们可以使用下面的方法获得宏展开之后的代码:

1
2
3
4
5
6
reasonml复制代码iex> expr = quote do: Unless.macro_unless true, do: IO.puts "this should never be printed"
iex> expr |> Macro.expand_once(__ENV__) |> Macro.to_string |> IO.puts
if(!true) do
IO.puts("this should never be printed")
end
:ok

当我们为 quote 函数传入一个表达式的时候,它会将当前的表达式转换成一个抽象语法树:

1
2
3
4
handlebars复制代码{{:., [], [{:__aliases__, [alias: false], [:Unless]}, :macro_unless]}, [],
[true,
[do: {{:., [], [{:__aliases__, [alias: false], [:IO]}, :puts]}, [],
["this should never be printed"]}]]}

在 Elixir 中,抽象语法数是可以直接通过下面的 Code.eval_quoted 方法运行:

1
2
3
4
5
6
less复制代码iex> Code.eval_quoted [expr]
** (CompileError) nofile:1: you must require Unless before invoking the macro Unless.macro_unless/2
(elixir) src/elixir_dispatch.erl:97: :elixir_dispatch.dispatch_require/6
(elixir) lib/code.ex:213: Code.eval_quoted/3
iex> Code.eval_quoted [quote(do: require Unless), expr]
{[Unless, nil], []}

我们只运行当前的语法树,我们会发现当前的代码由于 Unless 模块没有加载导致宏找不到报错,所以我们在执行 Unless.macro_unless 之前需要先 require 对应的模块。

elixir-macro

在最开始对当前的宏进行定义时,我们就会发现宏其实输入的是一些语法元素,实现内部也通过 quote 和 unquote 方法对当前的语法树进行修改,最后返回新的语法树:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
handlebars复制代码defmacro macro_unless(clause, do: expression) do
quote do
if(!unquote(clause), do: unquote(expression))
end
end

iex> expr = quote do: Unless.macro_unless true, do: IO.puts "this should never be printed"
{{:., [], [{:__aliases__, [alias: false], [:Unless]}, :macro_unless]}, [],
[true,
[do: {{:., [], [{:__aliases__, [alias: false], [:IO]}, :puts]}, [],
["this should never be printed"]}]]}

iex> Macro.expand_once expr, __ENV__
{:if, [context: Unless, import: Kernel],
[{:!, [context: Unless, import: Kernel], [true]},
[do: {{:., [],
[{:__aliases__, [alias: false, counter: -576460752303422687], [:IO]},
:puts]}, [], ["this should never be printed"]}]]}

Elixir 中的宏相比于 C 语言中的宏更强大,这是因为它不是对代码中的文本直接进行替换,它能够为我们直接提供操作 Elixir 抽象语法树的能力,让我们能够参与到 Elixir 的编译过程,影响编译的结果;除此之外,Elixir 中的宏还是卫生宏(Hygiene Macro),宏中定义的参数并不会影响当前代码执行的上下文。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
reasonml复制代码defmodule Example do
defmacro hygienic do
quote do
val = 1
end
end
end

iex> val = 42
42
iex> Example.hygienic
1
iex> val
42

在上述代码中,虽然宏内部的变量与当前环境上下文中的变量重名了,但是宏内部的变量并没有影响上下文中 val 变量的变化,所以 Elixir 中宏系统是『卫生的』,如果我们真的想要改变上下文中的变量,可以使用 var! 来做这件事情:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
pony复制代码defmodule Example do
defmacro unhygienic do
quote do
var!(val) = 2
end
end
end

iex> val = 42
42
iex> Example.unhygienic
2
iex> val
2

相比于使用文本替换的 C 语言宏,Elixir 的宏系统解决了很多问题,例如:卫生宏,不仅如此,Elixir 的宏还允许我们修改当前的代码中的语法树,提供了更加强大的表达能力。

Rust

Elixir 的宏系统其实已经足够强大了,不止避免了基于文本替换的宏带来的各种问题,我们还可以直接使用宏操作上下文的语法树,作者在一段时间内都觉得 Elixir 的宏系统是接触到的最强大的宏系统,直到开始学习 Rust 才发现更复杂的宏系统。

rust-logo

Rust 是一门非常有趣的编程语言,它是一门有着极高的性能的系统级的编程语言,能够避免当前应用中发生的段错误并且保证线程安全和内存安全,但是这些都不是我们今天想要关注的事情,与 Elixir 一样,在这篇文章中我们仅仅关心 Rust 的宏系统到底是什么样的:

1
2
3
4
puppet复制代码macro_rules! foo {
(x => $e:expr) => (println!("mode X: {}", $e));
(y => $e:expr) => (println!("mode Y: {}", $e));
}

上面的 Rust 代码定义了一个名为 foo 的宏,我们在代码中需要使用 foo! 来调用上面定义的宏:

1
2
3
javascript复制代码fn main() {
foo!(y => 3); // => mode Y: 3
}

上述的宏 foo 的主体部分其实会将传入的语法元素与宏中的条件进行模式匹配,如果匹配到了,就会返回条件右侧的表达式,到这里其实与 Elixir 的宏系统没有太大的区别,Rust 宏相比 Elixir 更强大主要在于其提供了更加灵活的匹配系统,在宏 foo 的定义中使用的 $e:expr 就会匹配一个表达式并将表达式绑定到 $e 这个上下文的变量中,除此之外,在
Rust 中我们还可以组合使用以下的匹配符:

rust-macro-matcher-and-example

为了实现功能更强大的宏系统,Rust 的宏还提供了重复操作符和递归宏的功能,结合这两个宏系统的特性,我们能直接使用宏构建一个生成 HTML 的 DSL:

1
2
3
4
5
6
7
8
9
10
11
12
handlebars复制代码macro_rules! write_html {
($w:expr, ) => (());

($w:expr, $e:tt) => (write!($w, "{}", $e));

($w:expr, $tag:ident [ $($inner:tt)* ] $($rest:tt)*) => {{
write!($w, "<{}>", stringify!($tag));
write_html!($w, $($inner)*);
write!($w, "</{}>", stringify!($tag));
write_html!($w, $($rest)*);
}};
}

在上述的 write_html 宏中,我们总共有三个匹配条件,其中前两个是宏的终止条件,第一个条件不会做任何的操作,第二个条件会将匹配到的 Token 树求值并写回到传入的字符串引用 $w 中,最后的条件就是最有意思的部分了,在这里我们使用了形如的 $(...)* 语法来匹配零个或多个相同的语法元素,例如 $($inner:tt)* 就是匹配零个以上的
Token 树(tt);在右侧的代码中递归调用了 write_html 宏并分别传入 $($inner)* 和 $($rest)* 两个参数,这样我们的 write_html 就能够解析 DSL 了。

有了 write_html 宏,我们就可以直接使用形如 html[head[title["Macros guide"]] 的代码返回如下所示的 HTML:

1
xml复制代码<html><head><title>Macros guide</title></head></html>

这一节中提供的与 Rust 宏相关的例子都取自 官方文档 中对宏的介绍这一部分内容。

Rust 的宏系统其实是基于一篇 1986 年的论文 Macro-by-Example 实现的,如果想要深入了解 Rust 的宏系统可以阅读这篇论文;Rust 的宏系统确实非常完备也足够强大,能够做很多我们使用 C 语言宏时无法做到的事情,极大地提高了语言的表达能力。

运行时(Runtime)

宏是一种能在程序执行的预编译或者编译期间改变代码行为的能力,通过编译期的处理过程赋予编程语言元编程能力;而运行时,顾名思义一般是指面向对象的编程语言在程序运行的某一个时间的上下文,在这里我们想要介绍的运行时可以理解为能够在运行期间改变对象行为的机制。

phases

当相应的行为在当前对象上没有被找到时,运行时会提供一个改变当前对象行为的入口,在篇文章中提到的运行时不是广义上的运行时系统,它特指面向对象语言在方法决议的过程中为外界提供的入口,让工程师提供的代码也能参与到当前的方法决议和信息发送的过程。

在这一节中,我们将介绍的两个使用了运行时的面向对象编程语言 Objective-C 和 Ruby,它们有着相似的消息发送的流程,但是由于 OOP 模型实现的不同导致方法调用的过程稍微有一些差别;除此之外,由于 Objective-C 是需要通过编译器编译成二进制文件才能执行的,而 Ruby 可以直接被各种解释器运行,所以两者的元编程能力也会受到这一差别的影响,我们会在下面展开进行介绍。

Objective-C

Objective-C 是一种通用的面向对象编程语言,它将 Smalltalk 消息发送的语法引入了 C 语言;ObjC 语言的面向对象模型其实都是运行在 ObjC Runtime 上的,整个运行时也为 ObjC 提供了方法查找的策略。

objc-class-hierachy

如上图所示,我们有一个 Dog 类的实例,当我们执行了 dog.wtf 方法时,运行时会先向右再向上的方式在整个继承链中查找相应的方法是否存在,如果当前方法在整个继承链中都完全不存在就会进入动态方法决议和消息转发的过程。

objc-message-resolution-and-forwarding

上述图片取自 从代理到 RACSignal,使用时对图片中的颜色以及字号稍作修改。

当 ObjC 的运行时在方法查找的过程中已经查找到了上帝类 NSObject 时,仍然没有找到方法的实现就会进入上面的流程,先执行的 +resolveInstanceMethod: 方法就是一个可以为当前的类添加方法的入口:

1
2
3
4
5
6
7
8
9
objectivec复制代码void dynamicMethodIMP(id self, SEL _cmd) { }

+ (BOOL)resolveInstanceMethod:(SEL)aSEL {
if (aSEL == @selector(resolveThisMethodDynamically)) {
class_addMethod([self class], aSEL, (IMP) dynamicMethodIMP, "v@:");
return YES;
}
return [super resolveInstanceMethod:aSel];
}

在这里可以通过 class_addMethod 动态的为当前的类添加新的方法和对应的实现,如果错过了这个入口,我们就进入了消息转发的流程;在这里,我们有两种选择,一种情况是通过 -forwardTargetForSelector: 将当前方法的调用直接转发到其他方法上,另一种就是组合 -methodSignatureForSelector: 和 -forwardInvocation: 两个方法,直接执行一个 NSInvocation 对象。

1
2
3
4
5
6
7
angelscript复制代码- (void)forwardInvocation:(NSInvocation *)anInvocation {
if ([someOtherObject respondsToSelector:[anInvocation selector]]) {
[anInvocation invokeWithTarget:someOtherObject];
} else {
[super forwardInvocation:anInvocation];
}
}

-forwardTargetForSelector: 方法只能简单地将方法直接转发给其他的对象,但是在 -forwardInvocation: 中我们可以得到一个 NSInvocation 实例,可以自由地选择需要执行哪些方法,并修改当前方法调用的上下文,包括:方法名、参数和目标对象。

虽然 Objective-C 的运行时系统能够为我们提供动态方法决议的功能,也就是某一个方法在编译期间哪怕不存在,我们也可以在运行时进行调用,这虽然听起来很不错,在很多时候我们都可以通过 -performSelector: 调用编译器看起来不存的方法,但是作为一门执行之前需要编译的语言,如果我们在 +resolveInstanceMethod: 中确实动态实现了一些方法,但是编译器在编译期间对这一切都毫不知情。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
objectivec复制代码void dynamicMethodIMP(id self, SEL _cmd) { }
+ (BOOL)resolveInstanceMethod:(SEL)aSEL {
NSString *selector = NSStringFromSelector(aSEL);
if ([selector hasPrefix:@"find"]) {
class_addMethod([self class], aSEL, (IMP) dynamicMethodIMP, "v@:");
return YES;
}
return [super resolveInstanceMethod:aSel];
}

- (void)func {
[self findFoo];
[self findBar];
[self find];
}

从 -func 中调用的三个以 find 开头的方法其实会在运行期间添加到当前类上,但是编译器在编译期间对此一无所知,所以它会提示编译错误,在编译期间将可以运行的代码拦截了下来,这样的代码如果跳过编译器检查,直接运行是不会出问题的,但是代码的执行必须通过编译器编译,这一过程是无法跳过的。

objc-compile-and-execute

我们只能通过 -performSelector: 方法绕过编译器的检查,不过使用 -performSelector: 会为代码添加非常多的噪音:

1
2
3
4
5
java复制代码- (void)func {
[self performSelector:@selector(findFoo)];
[self performSelector:@selector(findBar)];
[self performSelector:@selector(find)];
}

所以虽然 Objective-C 通过运行时提供了比较强大的元编程能力,但是由于代码执行时需要经过编译器的检查,所以在很多时候我们都没有办法直接发挥运行时为我们带来的好处,需要通过其他的方式完成方法的调用。

Ruby

除了 Objective-C 之外,Ruby 也提供了一些相似的运行时修改行为的特性,它能够在运行时修改自身特性的功能还是建立在它的 OOP 模型之上;Ruby 提供了一些在运行期间能够改变自身行为的入口和 API 可以帮助我们快速为当前的类添加方法或者实例变量。

ruby-class-hierachy

当我们调用 Dog 实例的一个方法时,Ruby 会先找到当前对象的类,然后在由 superclass 构成的链上查找并调用相应的方法,这是 OOP 中非常常见的,向右再向上的方法查找过程。

与 Objective-C 几乎相同,Ruby 也提供了类似与 +resolveInstanceMethod: 的方法,如果方法在整个继承链上都完全不存在时,就会调用 #method_missing 方法,并传入与这次方法调用有关的参数:

1
2
oxygene复制代码def method_missing(method, *args, &block)
end

传入的参数包括方法的符号,调用原方法时传入的参数和 block,在这里我们就可以为当前的类添加方法了:

1
2
3
4
5
6
7
8
9
10
11
12
ruby复制代码class Dog
def method_missing(m, *args, &block)
if m.to_s.start_with? 'find'
define_singleton_method(m) do |*args|
puts "#{m}, #{args}"
end
send(m, *args, &block)
else
super
end
end
end

通过 Ruby 提供的一些 API,例如 define_method、define_singleton_method 我们可以直接在运行期间快速改变对象的行为,在使用时也非常简单:

1
2
3
4
5
6
7
8
excel复制代码pry(main)> d = Dog.new
=> #<Dog:0x007fe31e3f87a8>
pry(main)> d.find_by_name "dog"
find_by_name, ["dog"]
=> nil
pry(main)> d.find_by_name "dog", "another_dog"
find_by_name, ["dog", "another_dog"]
=> nil

当我们调用以 find 开头的实例方法时,由于在当前实例的类以及父类上没有实现,所以就会进入 #method_missing 方法并为当前实例定义新的方法 #find_by_name。

注意:当前的 #find_by_name 方法只是定义在当前实例上的,存储在当前实例的单类上。

由于 Ruby 是脚本语言,解释器在脚本执行之前不会对代码进行检查,所以哪怕在未执行期间并不存在的 #find_by_name 方法也不会导致解释器报错,在运行期间通过 #define_singleton_method 动态地 定义了新的 #find_by_name 方法修改了对象的行为,达到了为对象批量添加相似功能的目的。

总结

在文章中介绍的两种不同的元编程能力,宏系统和运行时,前者通过预先定义好的一些宏规则,在预编译和编译期间对代码进行展开和替换,而后者提供了在运行期间改变代码行为的能力,两种方式的本质都是通过少量的代码生成一些非常相似的代码和逻辑,能够增强编程语言的表达能力并减少开发者的工作量。

无论是宏还是运行时其实都是简化程序中代码的一种手段,归根结底就是一种使用代码生成代码的思想,如果我们能够掌握这种元编程的思想并在编程中熟练的运用就能够很好地解决程序中一些诡异的问题,还能消灭重复的代码,提高我们运用以及掌控编程语言的能力,能够极大地增强编程语言的表达能力,所以元编程确实是一种非常重要并且需要学习的思想。

Reference

  • Metaprogramming
  • C++ 模板和 C# 泛型之间的区别(C# 编程指南)
  • C++ 模板和 Java 泛型有什么异同?
  • Macro (computer science)
  • Macros · Elixir Doc
  • Macros · GCC
  • C 语言宏的特殊用法和几个坑
  • Hygienic macro
  • Metaprogramming · ElixirSchool
  • Macros · Rust Doc
  • Macro-by-Example
  • Rust
  • 从源代码看 ObjC 中消息的发送
  • Dynamic Method Resolution
  • Message Forwarding
  • 从代理到 RACSignal
  • resolveInstanceMethod(_:)
  • Ruby Method Missing
  • Ruby Metaprogramming - Method Missing

关于图片和转载

知识共享许可协议
本作品采用知识共享署名 4.0 国际许可协议进行许可。
转载时请注明原文链接,图片在使用时请保留图片中的全部内容,可适当缩放并在引用处附上图片所在的文章链接,图片使用 Sketch 进行绘制。

关于评论和留言

如果对本文 谈元编程与表达能力 的内容有疑问,请在下面的评论系统中留言,谢谢。

原文链接:谈元编程与表达能力 · 面向信仰编程

Follow: Draveness · GitHub

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

多维分析的后台性能优化手段

发表于 2017-12-11

内容来源:本文转载自数据蒋堂公众号,经授权发布!

阅读字数:2969 | 5分钟阅读

摘要

多维分析法是高级统计分析方法之一,就是把一种产品或一种市场现象,放到一个两维以上的空间坐标上来进行分析。

多维分析就是针对一个事先准备好的数据立方体实施旋转、切片(切块)、钻取等交互操作的过程,经常也被直接称为OLAP。它的后台运算在结构上很简单,如果用SQL语法描述,大体形式为:

SELECT D,..., SUM(M), ... FROM C WHERE D'=d' AND ... GROUP BY D,...

即对立方体按某些维度分组汇总某些测度。其中C是数据立方体,D,…是选出维度,M,…是聚合测度,聚合函数也可以不是SUM。D’是切片维度,切块时条件为D IN (d,…),WHERE中还可以增加针对某些测度的条件,一般也就是选出某个区间内的值。

OLAP需要即时响应,对性能要求很高,而这个运算形式虽然很简单,但数据量大时的计算量也不小,如果不设法优化,效率就可能很差。下面我们介绍多维分析后台建设时几种经常被采用的性能优化手段。

预先汇总

预先汇总是早期OLAP产品常用的手段,简单地就是拿空间换时间。把部分或者全部维度组合(GROUP BY子句)的汇总值(SELECT中的聚合测度)先计算出来保存,以后的计算可以直接取出或从这些中间结果再计算,性能会好很多。

预先汇总占用的空间有点大。如果保存全部维度组合,一般应用场景下(十几到几十个维度,维度取值范围在几到几十之间),简单计算可知,空间占用会比原始立方体大数倍到数十倍((k1+1)*(k2+1)*…与k1*k2*…之间的比,还要考虑多种聚合函数)。虽然要保证即时响应时立方体都不会太大,但再大几十倍经常也还是难以接受的。

折衷办法是只保存部分维度组合。OLAP过程中在界面上呈现出来的分组维度(GROUP BY子句)不会太多,可以只汇总所有m个维度的组合,在m不太大时(一般不超过5),空间增长还可以容忍,而用户的大多数操作都可以得到较迅速响应。

麻烦在于,部分汇总解决不了针对其它维度的切片条件,钻取动作就是以切片为基础的。而且,即使全量汇总也无法处理测度上的条件(比如销售额超过1000元的统计),而多维分析时常常允许这些动作,甚至聚合函数也可能带有条件(只合计100元以下的费用),这些都无法使用预先汇总的结果。

预先汇总只能解决小部分最常见的计算,更多的情况还是要靠硬遍历。

分段并行

多维分析本质上是过滤和分组汇总,这种运算很容易并行。只要简单地数据拆成多段后分别处理,收集到结果再汇总。各个子任务之间没有依赖关系,无论是单机多线程还是集群多机或者综合有之,都不难实现。

多维分析的结果是要呈现给人看的,而人可以观察的数据量远远小于现代计算机的内存。可以放入内存的小结果集不需要和外存交换,程序设计复杂度较低,运算性能也好。如果运算时发现结果集太大是可以直接报告给界面相应信息并中止。

实践测试表明:多线程计算时,不要采用各子任务向同一个结果集汇总的方案,这样看起来会减少内存占用(各子任务共用一个最终结果集),但多线程抢占同一资源需要的同步动作会严重影响性能。

线程数也不是越多越好,显然超过CPU核数就没有意义了。如果数据在外存,还要考虑硬盘的并发能力,一般会比CPU核数小很多,具体合适的数值需要实际测试才知道。

在数据不再变化时分段也容易,按记录数切分后设置分段点即可。数据可追加时要做到较平均的分段会有些麻烦,以后再另外撰文陈述。

对于单个计算任务,并行后常常有数倍的性能提升。但是,OLAP操作本身就是个并发性事务,即使用户数不大,也足以抵消并行计算带来的性能提升。

还要再想办法。

排序索引

没有切片的汇总运算总是要涉及全量数据,如果不是预先汇总,也没什么办法再减少计算量了。但有切片运算时(钻取动作),如果数据能合理组织,就未必要遍历所有数据了。

如果我们为维度D建立索引(即把各记录的D值及记录位置按D值排序),那么涉及D的切片条件就可以迅速定位到相应的记录上(简单二分法),不需要遍历全量数据,计算量常常会有数量级的减少(取决于D的取值范围)。理论上我们可以为每个维度都建立索引,这个成本并不算高,这样只要涉及有切片时,性能就会大幅提升。

需要指明的是,为多个维度D1,D2建立的多字段索引用处并不大,它不能用于迅速定位只有D2的切片,只能用于对D1,D2都有切片条件的情况。在选择取值范围最大的那个切片维度用于定位后,计算量减少已经很多了,其它维度的切片可以仍用遍历手段。

不幸的是,这种原始方案只适用于可以频繁小量访问的内存数据。如果数据量大到必须放在外存中(而这是经常发生的),按索引大量取出实际上并未连续存储的数据时,性能并不会有明显提高。外存数据必须被真实排序、保证相应切片的数据是连续存储的,性能提升才会有效。

如果对每个维度都做排序,那相当于数据要被复制若干倍,这个成本就有点高了。

一个折衷的办法是把做两个,按维度D1,…,Dn排序一次,再按Dn,…,D1排序一次,数据量只是翻倍,还能容忍。总能找到一个切片维度在两个维度排序列的前半部分,这样该维度切片的数据还是基本连续的,性能提升仍会较为明显。

列存压缩

对付多维分析还有个大杀器:列式存储。

多维分析的立方体中字段(维度和测度)常常都很多,几十个上百个都很正常,但同时需要取用的字段并不多,如果不算切片维度,通常也就5个左右或更少。而切片可以用上面的索引方案解决,实际要遍历的字段也仍然不多。

这时候列存就会有巨大优势了。外存计算的IO时间占比相当大,减少数据读取量比减少运算量常常能更有效地提高性能。一个100个字段的立方体,如果只取5个字段时,IO开销只有1/20,这会带来数量级的性能提升。

列存还有个优势是可以压缩数据量。如果按前述所说将数据按维度D1,…,Dn排序存储,我们会发现D1在连续许多记录中取值都相同,D2也是类似,但程度会弱一些,越往后的维度连续相同的程度越弱,Dn就会几乎没有相同连续值。连续相同的值没必要重复存储,可以只存一次并记录个数,这样将可以进一步减少存储量,也就是减少外存IO访问量,从而提高性能。

当然,列存也并不全是好处。

因为不减少计算量,列存对于内存数据用处不大。不过压缩存储方式仍然有意义,可以减少内存占用。

使用列存会使分段并行及建立索引的处理变得更复杂,各个列需要同步分段才能并行处理,索引也需要同步指向所有列,而使用压缩机制后同步更为麻烦。不过,总得来讲,在数据已经确定不再变化时,虽然麻烦,但难度并不算大,只是别忘处理了就行。

列存还会加大硬盘的并发压力,在总字段数不多或取用字段较多时并没有优势。对于机械硬盘,如果再使用并行手段进一步加剧并发压力,很可能导致性能不升反降的结果,对于易于并发的固态硬盘使用列存较为合适。

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

你所不知道的Java之HashCode

发表于 2017-12-11

以下内容为作者辛苦原创,版权归作者所有,如转载演绎请在“光变”微信公众号留言申请,转载文章请在开始处显著标明出处。

之所以写HashCode,是因为平时我们总听到它。但你真的了解hashcode吗?它会在哪里使用?它应该怎样写?

相信阅读完本文,能让你看到不一样的hashcode。

使用hashcode的目的在于:使用一个对象查找另一个对象。对于使用散列的数据结构,如HashSet、HashMap、LinkedHashSet、LinkedHashMap,如果没有很好的覆写键的hashcode()和equals()方法,那么将无法正确的处理键。

请对以下代码中Person覆写hashcode()方法,看看会发生什么?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
复制代码// 覆写hashcode
@Override
public int hashCode() {
return age;
}

@Test
public void testHashCode() {
Set<Person> people = new HashSet<Person>();
Person person = null;
for (int i = 0; i < 3 ; i++) {
person = new Person("name-" + i, i);
people.add(person);
}
person.age = 100;
System.out.println(people.contains(person));
people.add(person);
System.out.println(people.size());
}

运行结果并不是预期的true和3,而是false和4!改变person.age后HashSet无法找到person这个对象了,可见覆写hahcode对HashSet的存储和查询造成了影响。

那么hashcode是如何影响HashSet的存储和查询呢?又会造成怎样的影响呢?

HashSet的内部使用HashMap实现,所有放入HashSet中的集合元素都会转为HashMap的key来保存。HashMap使用散列表来存储,也就是数组+链表+红黑树(JDK1.8增加了红黑树部分)。 存储结构简图如下:

HashMap存储结构简图
数组的默认长度为16,数组里每个元素存储的是一个链表的头结点。组成链表的结点结构如下:

1
2
3
4
5
6
7
复制代码static class Node<K,V> implements Map.Entry<K,V> {
final int hash;
final K key;
V value;
Node<K,V> next;
...
}

每一个Node都保存了一个hash—-键对象的hashcode,如果键没有按照任何特定顺序保存,查找时通过equals()逐一与每一个数组元素进行比较,那么时间复杂度为O(n),数组长度越大,效率越低。

所以瓶颈在于键的查询速度,如何通过键来快速的定位到存储位置呢?

HashMap将键的hash值与数组下标建立映射,通过键对象的hash函数生成一个值,以此作为数组的下标,这样我们就可以通过键来快速的定位到存储位置了。如果hash函数设计的完美的话,数组的每个位置只有较少的值,那么在O(1)的时间我们就可以找到需要的元素,从而不需要去遍历链表。这样就大大提高了查询速度。

那么HashMap根据hashcode是如何得到数组下标呢?可以拆分为以下几步:

  • 第一步:h = key.hashCode()
  • 第二步:h ^ (h >>> 16)
  • 第三步:(length - 1) & hash

分析

第一步是得到key的hashcode值;

第二步是将键的hashcode的高16位异或低16位(高位运算),这样即使数组table的length比较小的时候,也能保证高低Bit都参与到Hash的计算中,同时不会有太大的开销;

第三步是hash值和数组长度进行取模运算,这样元素的分布相对来说比较均匀。当length总是2的n次方时,h & (length-1)运算等价于对length取模,这样模运算转化为位移运算速度更快。

但是,HashMap默认数组初始化容量大小为16。当数组长度远小于键的数量时,不同的键可能会产生相同的数组下标,也就是发生了哈希冲突!

对于哈希冲突有开放定址法、链地址法、公共溢出区法等解决方案。

开放定址法就是一旦发生冲突,就寻找下一个空的散列地址。过程可用下式描述:

fi(key) = (f(key) + di) mod m (di=1,2,3,…,m-1)

例如键集合为{12,67,56,16,25,37,22,29,15,47,48,34},表长n = 12,取f(key) = key mod 12。

前5个计算都没有冲突,直接存入。如表所示

数组下标 键
0 12
1 25
2
3
4 16
5
6
7 67
8 56
9
10
11

当key = 37时,f(37) = 1,与25的位置冲突。应用公式f(37) = (f(37) + 1) mod 12 = 2,所以37存入数组下标为2的位置。如表所示

数组下标 键
0 12
1 25
2 37
3
4 16
5
6
7 67
8 56
9
10
11

到了key = 48,与12所在的0冲突了。继续往下找,发现一直到f(48) = (f(48) + 6) mod 12 = 6时才有空位。如表所示

数组下标 键
0 12
1 25
2 37
3
4 16
5 29
6 48
7 67
8 56
9
10 22
11 47

所以在解决冲突的时候还会出现48和37冲突的情况,也就是出现了堆积,无论是查找还是存入效率大大降低。

链地址法解决冲突的做法是:如果哈希表空间为[0~m-1],设置一个由m个指针分量组成的一维数组Array[m], 凡哈希地址为i的数据元素都插入到头指针为Array[i]的链表中。

它的基本思想是:为每个Hash值建立一个单链表,当发生冲突时,将记录插入到链表中。如图所示:

链地址法
链表的好处表现在:

  1. remove操作时效率高,只维护指针的变化即可,无需进行移位操作
  2. 重新散列时,原来散落在同一个槽中的元素可能会被散落在不同的地方,对于数组需要进行移位操作,而链表只需维护指针。
    但是,这也带来了需要遍历单链表的性能损耗。

公共溢出法就是我们为所有冲突的键单独放一个公共的溢出区存放。
例如前面例子中{37,48,34}有冲突,将他们存入溢出表。如图所示。

公共溢出法
在查找时,先与基本表进行比对,如果相等则查找成功,如果不等则在溢出表中进行顺序查找。公共溢出法适用于冲突数据很少的情况。

HashMap解决冲突采取的是链地址法。整体流程图(暂不考虑扩容)如下:

HashMap存储流程简图
理解了hashcode和哈希冲突即解决方案后,我们如何设计自己的hashcode() 方法呢?

Effective Java一书中对覆写hashcode()给出以下指导:

  • 给int变量result赋予某个非零常量值
  • 为对象内每个有意义的域f计算一个int散列码c
域类型 计算
boolean c = (f ? 0 : 1)
byte、char、short、int c = (int)f
long c = (int)(f ^ (f >>> 32))
float c = Float.floatToIntBits(f)
double long l = Double.doubleToIntLongBits(f)
c = (int)(l ^ (l >>> 32))
Object c = f.hashcode()
数组 每个元素应用上述规则
boolean c = (f ? 0 : 1)
boolean c = (f ? 0 : 1)
  • 合并计算得到散列码 result = 37 * result + c

现代IDE通过点击右键上下文菜单可以自动生成hashcode方法,比如通过IDEA生成的hashcode如下:

1
2
3
4
5
6
复制代码@Override
public int hashCode() {
int result = name != null ? name.hashCode() : 0;
result = 31 * result + age;
return result;
}

但是在企业级代码中,最好使用第三方库如Apache commons来生成hashocde方法。使用第三方库的优势是可以反复验证尝试代码。下面代码显示了如何使用Apache Commons hash code 为一个自定义类构建生成hashcode。

1
2
3
4
5
6
7
复制代码public int hashCode(){
HashCodeBuilder builder = new HashCodeBuilder();
builder.append(mostSignificantMemberVariable);
........................
builder.append(leastSignificantMemberVariable);
return builder.toHashCode();
}

如代码所示,最重要的签名成员变量应该首先传递然后跟随的是没那么重要的成员变量。

总结

通过上述分析,我们设计hashcode()应该注意的是:

  • 无论何时,对同一个对象调用hashcode()都应该生成同样的值。
  • hashcode()尽量使用对象内有意义的识别信息。
  • 好的hashcode()应该产生分布均匀的散列值。

感谢觉醒和飞鸟的宝贵建议和辛苦校对。

关注公众号

如果文章对你有所帮助,请给作者块糖吃。 可以关注我们的公众号,定期发布高质量文章。

光变:微信公众号

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

从外部编码的角度再议Java乱码问题 从外部编码的角度再议J

发表于 2017-12-11

从外部编码的角度再议Java乱码问题

在实际项目中,由于系统的复杂性,乱码的根源往往不容易快速定位,乱码问题不见得一定能通过在 Java 内部编解码的方式解决。正确的做法应该是依次检查输入 Java 虚拟机(以下简称 JVM)前的字符串、JVM 内的字符串、输出 JVM 的字符串是否正常。自字符串经过某种形式读入到经过某种方式写出,只有整条流水线完美配合才能彻底杜绝乱码的出现。

这就要求我们不仅要搞懂 Java 内部在输入输出时处理字符编解码的机制,还要了解操作系统的语言区域设置和 JVM 的启动参数对 JVM 编译运行时的默认编码(部分 API 无法显式指定编码,只能使用默认编码)的影响、Eclipse 中编码设定的承继关系、负责显示的终端/控制台的编码/代码页对字符解析的影响等一系列问题。

本文将以一个在实际项目中遇到的乱码问题作为出发点,依次从问题描述、故障排除、根源分析等层次递进剖析该问题,继而探寻乱码产生的本质。读者不仅能够通过本文深入理解外部编码导致乱码这一问题,还能从本文解决问题的思维方法中获得经验和教训。

从一个真实项目中遇到的乱码问题谈起

问题描述

曾经在项目中遇到过一个耗时比较久的乱码问题,问题的表象是报出了一个创建文件夹失败(由乱码导致)的异常。由于系统比较复杂、难于调试且笔者对 JVM 默认编码和相关源码不够熟悉,经过了很长时间的研究和多人协作进行故障排除后才精准定位到了问题根源。

如图一所示,该系统涉及了 C/C++,Java,shell 等多个模块,模块间的调用关系也比较复杂,部分通过 SOAP,部分通过 JNI,甚至有少部分通过环境变量共享数据的情况–这也是本问题的核心所在。

图 1. 问题所处系统结构

故障排除

由于在项目进行过程中大部分乱码问题都出现在了涉及操作系统的后端逻辑,因此笔者起初在解决该问题时就未对 Java 部分引起足够的重视,最后的结果也证明这是极不可取的。这是笔者在解决该问题时犯的根本性错误,先入为主的偏见。

排错过程及相关教训

  • 解决该问题的突破点是前台看到的”创建文件夹<含有乱码的文件路径>失败”这个异常,搜索代码发现该异常位于本项目 Jar 包中,顺着异常往前追溯代码,在过滤掉一系列并不会导致乱码的 Java 内部操作后追踪到了 Java 与外部交互的地方:System.getenv(“key”)。异常中提到的文件夹名字的一部分来自该函数取得的环境变量,而 Java 取得的该环境变量是乱码。查看 Java API 后发现该函数并无可指定编码的重载函数,也没找到可以查看编码的存储环境变量的文件,因此我们得出了问题不在
    Java 部分的结论(此处犯错的原因在于没有查看 JDK 源码的习惯)。
  • 接下来 C/C++模块的同事查看相关日志后发现日志中的该字串是正常的,因此怀疑问题出在 System.getenv(“key”)并请求调试 Java 部分代码。
  • 由于对系统的整个编译构建过程不够熟悉,笔者并没有加断点在线调试或者修改代码查看输出,而是编写了简单的 shell 和 Java 代码去仿真验证当环境变量被正确设置时 Java 能否正常读取。虽然结果是肯定的,但是由于对 JVM 的系统属性和默认编码机制不敏感,笔者忽略了该仿真并不能完全模拟运行时的问题,系统运行时启动的 shell 和试验中 shell 的编码是不同的,由此导致 JVM 内的默认编码是不同的,这也是该问题的根源。
  • 当把仿真的结果通知 C/C++模块的同事并与之解释清楚 Java 端也并不容易调试之后,我们将该问题分两个方向继续进行研究:一是笔者尝试搭建 Java 模块的调试环境;二是 C/C++模块的同事进行代码走查查看 C/C++模块是否有出错的可能。
  • 接下来 C/C++模块的同事给出的结论是该环境变量在 C/C++模块是正确的,而且因为 Linux 平台没有类似 Windows 平台的 ANSI 格式以及转码的问题,所以非英文字串在 Linux 平台内部的传递和转换都不应出现乱码。
  • 得知上述结论后笔者在 System.getenv(“key”)并不会出问题的这个错误假设的基础上再次走查 Java 部分的代码,走查后发现取完环境变量之后只是做了静态变量获取、字符串拼接等普通操作,并没有可以引起乱码的可疑代码。之后笔者”惊奇”地发现 Java 部分并没有去创建目录,而是把目录字符串通过 JNI 传递给 C/C++模块,然后就得出了问题可能出在 JNI 或者 C/C++模块二的错误结论。其实这个结论现在看起来非常荒谬,因为从清单 1 可以明显看出在进行 JNI 调用前已经出现了乱码。
清单 1. 抛出异常的 Java 代码
1
2
3
4
5
复制代码File dirFile = new File(folderPath);
Utils.createPathViaJNI(serviceContext, folderPath, sessionId);
if (!dirFile.exists() || !dirFile.isDirectory()) {
   throw new IOException("failed to create folder <" + folderPath + ">");
}
  • 最终,C/C++模块的同事发现 C/C++模块 2 得到的字串已经是乱码,并且 C/C++模块 2 直接去获取环境变量时可以得到正确的值。目前看来问题还是出在了 JNI 部分(其实是 Java 部分,因为 6 中有谬误)。
  • 在绕了一圈回到原点后笔者终于搭建了 Java 部分的调试环境,结果发现 Java 中取到的环境变量已经是乱码(其实该点通过异常信息和代码走查即可看出)。至此问题才开始变得清晰,综上可得 C/C++模块 1 设了环境变量、C/C++模块 2 去取是没问题的,Java 去取是有问题的。此时由于笔者搜索源码发现 C/C++模块中还有其他设置该环境变量的地方,因此亦曾怀疑是否有可能 Java 取环境变量的前后有其他部分的 C/C++代码分别把该环境变量设置了多次,Java 部分之前设置了乱码,Java
    部分之后设置了正确的。现在回想起来该推测相当不着边际,当然这一猜测在当时也被 C/C++模块的同事予以直接驳回。
  • 虽然事实残酷,但越来越多的证据表明问题出在了 Java 部分。此时笔者首先在运行时 Linux shell 2 中打印该环境变量发现该环境变量是正确的,这就更加佐证了问题出在 System.getenv(“key”)中这个论断。不过问题在于最初的仿真实验表明如果 shell 中的环境变量正确, Java 中取是没问题的。此时笔者开始怀疑 shell 中运行 jar 包时是否需要添加某个参数,或者本地的编译环境和构建环境不一致。
  • 至此对该问题的分析终于走向了正轨且确认了问题根源的前置边界,笔者又在 Java 代码的 main 函数的入口处打印了该环境变量以证明调用 Java 后异常报出前并没有调用 JNI 重新设置过环境变量,至此已基本断定问题出在 System.getenv(“key”)。
  • 在与另一个有经验的同事后讨论后,怀疑的重点放到了 shell 和 JVM 的 locale 设置上。然后做了一系列结果如表 1 的对比试验。
表 1. 获取非英文 Linux 环境变量对比试验结果
何处设置 仿**真 shell 取** 仿**真 Java 取** 产**品 shell 取** 产**品 Java 取**
仿真 shell 正常 正常 无法获取 无法获取
产品 shell 无法获取 乱码 正常 乱码
  • 最终通过在 Java 代码中打印系统属性揭开了谜底。表 1 中仿真 shell 和产品 shell 的核心区别在于区域语言的设置,前者为 zh_CN.UTF-8,后者为空,如图 2 所示。
图 2. 仿真 shell 和产品 shell 的区域语言设置


导致的结果就是在对应环境无参启动 JVM 后得到的 file.encoding 系统属性即默认字符集分别是 UTF-8 和 ANSI,如图 3 所示。

图 3.仿真 shell 启动的 JVM 系统属性和产品 shell 启动的 JVM 系统属性

  • 最后该问题通过在 Linux shell 2 中运行 jar 包时将添加-Dfile.encoding=UTF-8 参数得以解决。

正确的排错过程

  • 找到异常位置后应该首先查看抛出异常的地方是不是导致问题的地方,检查异常是否是由于更底层的异常导致的。如上文清单 1 所示,异常信息中出现的乱码在进行 JNI 调用前已经出现(String 类型的 folderPath 作为值传递并不会发生改变),因此可以暂时排除后续 JNI 调用导致的问题。
  • 在第一步缩小了问题根源的后置边界后,参考图一可得下一步需要排除的即是明确问题到底是出在 Linux shell 2 之前还是本项目 Jar 包中。结果通过在 Linux shell 2 中运行 Jar 包前打印环境变量发现字符串是正确的,此时基本可以断定问题出在 Java 部分。
  • 之后通过走查 Java 部分代码过滤掉绝对不会导致乱码的部分后发现只有 System.getenv(“key”)这一 Java 与外部交互的接口,问题只可能出在这里。
  • 到这里如果对字符串的本质能有个清晰的认识的话应该就能找到问题的解决方向。虽然查资料不容易找到环境变量的存储机制,但无论是在文件中还是内存中总归应该是以某种编码编组的二进制形式存在,然后 getenv 读取的时候再采用相应的编码去解码即可成功创建字符串。既然 getenv 无法显式指定外部编码的话那么应该是采用了默认编码去解码,问题估计出现在了这里。
  • 最终通过打印产品 JVM 中的默认编码发现为 ANSI,尝试在启动 JVM 时显式指定 file.encoding 为 UTF-8,问题得解。

根源分析

目前我们观察到的现象是基于 Linux 版的 JDK 获取系统环境变量时如果默认字符集不是”UTF-8”时会得到乱码。那么 windows 平台呢?试验的结果是默认字符集无论改为 GB18030,UTF-8,ISO-8859-1 甚至 US-ANSI,读进来的 sysenv 都是正确的,看来 windows 版的 JDK 不存在该问题,该问题与 getenv 在不同平台的实现有关。

在深入源码分析前我们可以大胆地做一下猜测,众所周知 windows 的内部编码是 UTF-16,那么其环境变量无论是存在哪里估计都是按 UTF-16 编码的字节数组。而恰恰 Java 内部的编码也是 UTF-16,可以想见 getenv 在 windows 上的 JNI 本地实现是相对简单的,JNI 本地实现中可以直接解码返回 Java 字符串,无需 JDK 再去解码字节数组。

与之对应的是 Linux 的内部编码是 UTF-8,该问题出现的原因很可能是 getenv 的 Linux 本地实现返回的是 UTF-8 编码的字节数组。Linux 版的 JDK 采用默认编码 ANSI 去解码该字节数组创建字符串时导致了乱码。

从外部编码的角度解读 Java 乱码问题

Java 编码问题简述

编译阶段

首先需要澄清的是,作为二进制文件,无论在什么系统和字符集下编译,Java 编译后的字节码文件(.class)始终以 UTF-8 编码。

与此不同的是,作为文本文件,Java 源文件(.java)则可能是以特定文化的字符集编码(比如中文 windows 下的 GBK)的。无参执行 Javac 进行编译时会默认使用操作系统的编码编译文件。此时若创建文件和编译文件在同一系统且系统编码未变则不会出现问题;否则若文件的编码与编译时系统的默认编码不一致,在无参执行 Javac 编译时就会出现错误,需要在编译时通过显式添加”-encoding enc”参数来解决。

运行阶段

同样首先需要强调的是在 JVM 内字符串采用 Unicode(UTF-16)编码,Java 系统内部不会出现乱码问题。与 Java 相关的乱码问题一般是发生在输入输出阶段,因为外部资源的编码(比如 Linux 里的环境变量是 UTF-8 编码的)不见得就是 Unicode(UTF-16)编码的,所以在输入输出的时候就需要指定外部编码以便其能和 Java 内的 Unicde 编码做转换动作。

比如读文件时给输入流指定编码的意思就是

将把采用何种编码方式编组的字节/字符流转换为 Java 内以 Unicode 编码的字符串;写文件时指定编码意思就是将采用何种编码将 Java 内以 Unicode 编码的字符编组为字节数组写到文件里。

Java 中输入输出相关的 API 一般都有是否指定外部字符集的重载形式,选择不指定外部字符集形式的函数时将使用默认字符集,即 Charset.defaultCharset()。查看相关源码(见清单 2)可见 defaultCharset 由系统属性 file.encoding 决定。再进一步,若 JVM 启动时未在启动参数中添加相应的系统属性参数”-Dfile.encoding=enc”,JVM 中的该系统属性在默认情况下由启动该 JVM 的环境决定,比如当前控制台的编码或 Eclipse
运行时编码。

清单 2. Charset.defaultCharset()源码
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
复制代码/**
 * Returns the default charset of this Java virtual machine.
 *
 * <p> The default charset is determined during virtual-machine startup and
 * typically depends upon the locale and charset of the underlying
 * operating system.
 *
 * @return  A charset object for the default charset
 *
 * @since 1.5
 */
 
import sun.security.action.GetPropertyAction;
public static Charset defaultCharset() {
    if (defaultCharset == null) {
        synchronized (Charset.class) {
            String csn = AccessController.doPrivileged(
                new GetPropertyAction("file.encoding"));
            Charset cs = lookup(csn);
            if (cs != null)
                defaultCharset = cs;
            else
                defaultCharset = forName("UTF-8");
        }
    }
    return defaultCharset;
}

与 Java 乱码相关的外部编码详解

操作系统中的系统编码与 JVM 系统属性参数

上节提到在未显式指定相关参数的背景下,命令行下编译时 javac 将基于系统编码编译 Java 源文件,运行时也将使用系统编码初始化 file.encoding 这一系统属性,继而影响默认编码和使用默认编码的输入输出函数。

系统编码可以说是操作系统区域语言设置的一部分,或者像 windows 系统那样区域语言的设定决定了系统编码(比如 windows 系统区域设置为”英语(美国)”时代码页/系统编码为 437 或 Cp1252,系统区域设置为”中文(简体,中国)”时代码页/系统编码为 936(gbk),或者像 Linux 那样将系统编码作为区域语言设定的补充(比如 LANG=en-US.UTF-8)。

Java 应用程序在 JVM 内运行,而 JVM 在操作系统内运行。从 Java 应用程序的角度看,JVM 和操作系统均是其所处的系统环境,因此 JVM 和操作系统中的属性都被称为系统属性。无参状态启动 JVM 时其将根据自己所处的系统环境初始化这些系统属性参数,有参启动时则可以通过添加-D 参数指定具体的系统属性的值。

上文已经提到了 file.encoding 这个属性会影响 JVM 内的默认编码,另外笔者阅读相关源码发现个别 API 还会依赖 sun.stdout.encoding 这一属性对应的编码,后文对部分 JDK 源码的分析部分将详细阐述该问题。

有意思的是试验发现在 windows 系统区域设置为英语(美国)时,命令行下通过 chcp 查看当前代码页为 437,在该命令行无参启动 JVM 后得到的 file.encoding 为 Cp1252,sun.stdout.encoding 为 cp437;Eclipse 基于系统取得的编码为 Cp1252,默认运行后得到的 file.encoding 为 Cp1252,sun.stdout.encoding 为空。结果如表 2 所示:

表 2. Windows 平台 JVM 系统属性与操作系统区域设置的对应关系
系统区域 启动环境 环境编码 file.encoding sun.stdout.encoding
英语(美国) 命令行 437 Cp1252 cp437
英语(美国) Eclipse Cp1252 Cp1252 null
中文(简体,中国) 命令行 936 GBK ms936
中文(简体,中国) Eclipse GBK GBK null

Eclipse 中编码设定的承继关系

上文曾提到 JVM 中的默认字符集由其获得的系统属性 file.encoding 决定,而该系统属性在未指定相应启动参数时由启动该 JVM 的环境编码决定(参考表 2),操作系统命令行中的环境编码比较容易理解,即为该运行时的区域语言设置决定的编码或者代码页,那么 Eclipse 中呢?

研究发现在 Eclipse 中运行 Java 应用时,若未在运行配置的参数设定页面指定相关 JVM 启动参数,file.encoding 属性由通用设置页面的编码决定,而该编码默认情况下继承自 main 函数所在源码文件的编码类型,该源码文件的编码类型默认继承自该项目的文本文件编码类型,该项目的文本文件编码类型默认继承自该工作空间的文本文件编码类型,最终该工作空间的文本文件编码类型由系统编码决定,如图 4 所示自上而下展示了这种承继关系。

图 4. Eclipse 中编码设定的承继关系

显示终端的编码与代码页设定

在涉及 Java 字符串流转的整个生命周期中,起点是某种形式(比如某种输入流)的读入,而终点则是某种形式的存储(比如写入文件)或显示(比如调试打印用的各种控制台)。即使输入输出都没问题,控制台解析 Java 输出流时所采用的编码与该流的编码不一致的话仍然会出现乱码。

一般常见的显示终端有系统终端(windows 或 Linux 的命令行),远程桌面终端和 Eclipse 中的输出控制台。

  • 命令行的编码由当前环境的代码页或系统编码控制,windows 和 Linux 下可分别使用 chcp 和 locale 命令查看
  • 远程桌面终端比如 Putty 控制台的编码不受所访问的系统的控制,需要在”变更设置》窗口》翻译转换”中配置。
  • Eclipse 中的控制台编码由运行配置中的通用设定页的编码决定,该编码的承继关系可参考上节内容。

通过解读 JDK 源码深入理解部分 API 对外部编码的依赖

System.getenv

分析清单 3 可看出,System.getenv 将调用 ProcessEnvironment.getenv(),正如我们在上文猜测的那样,ProcessEnvironment 最终调用的 JNI 函数 environmentBlock()返回的为完整的环境变量字符串,JDK 中并没有涉及解码的问题。因此无论默认编码是什么编码,window JDK 取到的环境变量都不是乱码。

清单 3. System.getenv 在 windows 上的实现
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
复制代码public static String getenv(String name) {
    SecurityManager sm = getSecurityManager();
    if (sm != null) {
        sm.checkPermission(new RuntimePermission("getenv."+name));
    }
    return ProcessEnvironment.getenv(name);
}
 
static {
    ...
    String envblock = environmentBlock();
    int beg, end, eql;
    for (beg = 0;
         ((end = envblock.indexOf('\u0000', beg  )) != -1 &&
          // An initial `=' indicates a magic Windows variable name -- OK
          (eql = envblock.indexOf('='     , beg+1)) != -1);
         beg = end + 1) {
        // Ignore corrupted environment strings.
        if (eql < end)
            theEnvironment.put(envblock.substring(beg, eql),
                               envblock.substring(eql+1,end));
    }
    theCaseInsensitiveEnvironment = new TreeMap<>(nameComparator);
    theCaseInsensitiveEnvironment.putAll(theEnvironment);
}
...
// Only for use by System.getenv(String)
static String getenv(String name) {
    // ...
    return theCaseInsensitiveEnvironment.get(name);
}
...
private static native String environmentBlock();
/* Returns a Windows style environment block, discarding final trailing NUL */
JNIEXPORT jstring JNICALL
Java_Java_lang_ProcessEnvironment_environmentBlock(JNIEnv *env, jclass klass)
{
    int i;
    jstring envblock;
    jchar *blockW = (jchar *) GetEnvironmentStringsW();
    if (blockW == NULL)
        return environmentBlock9x(env);
    /* Don't search for "\u0000\u0000", since an empty environment
       block may legitimately consist of a single "\u0000".  */
    for (i = 0; blockW[i];)
        while (blockW[i++]);
    envblock = (*env)->NewString(env, blockW, i);
    FreeEnvironmentStringsW(blockW);
    return envblock;
}

分析清单 4 可看出,Linux 版的 JDK 中 ProcessEnvironment 最终调用的 JNI 函数 environ ()返回的为二维字节数组,在解析该字节数组构建环境变量字符串时用到了 new String()的不指定外部字符集的形式,这就对默认编码形成了依赖,如果在外部编码不为 UTF-8 时无参启动 JVM 读取的非英文 Linux 环境变量就会出现乱码-即本文开头提到的问题的源码级根源。

清单 4. System.getenv 在 Linux 上的实现
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
复制代码static {
    // We cache the C environment.  This means that subsequent calls
    // to putenv/setenv from C will not be visible from Java code.
    byte[][] environ = environ();
    theEnvironment = new HashMap<>(environ.length/2 + 3);
    // Read environment variables back to front,
    // so that earlier variables override later ones.
    for (int i = environ.length-1; i > 0; i-=2)
        theEnvironment.put(Variable.valueOf(environ[i-1]), Value.valueOf(environ[i]));
    theUnmodifiableEnvironment = Collections.unmodifiableMap
        (new StringEnvironment(theEnvironment));
}
/* Only for use by System.getenv(String) */
static String getenv(String name) {
    return theUnmodifiableEnvironment.get(name);
}
...
private static native byte[][] environ();
...
private static class Value extends ExternalData implements Comparable<Value>
{
    ...
    public static Value valueOf(byte[] bytes) {
        return new Value(new String(bytes), bytes);
    }
    ...
}
 
JNIEXPORT jobjectArray JNICALL
Java_Java_lang_ProcessEnvironment_environ(JNIEnv *env, jclass ign)
{
    jsize count = 0;
    jsize i, j;
    jobjectArray result;
    jclass byteArrCls = (*env)->FindClass(env, "[B");
 
    for (i = 0; environ[i]; i++) {
        /* Ignore corrupted environment variables */
        if (strchr(environ[i], '=') != NULL)
            count++;
    }
 
    result = (*env)->NewObjectArray(env, 2*count, byteArrCls, 0);
    if (result == NULL) return NULL;
 
    for (i = 0, j = 0; environ[i]; i++) {
        const char * varEnd = strchr(environ[i], '=');
        /* Ignore corrupted environment variables */
        if (varEnd != NULL) {
            jbyteArray var, val;
            const char * valBeg = varEnd + 1;
            jsize varLength = varEnd - environ[i];
            jsize valLength = strlen(valBeg);
            var = (*env)->NewByteArray(env, varLength);
            if (var == NULL) return NULL;
            val = (*env)->NewByteArray(env, valLength);
            if (val == NULL) return NULL;
            (*env)->SetByteArrayRegion(env, var, 0, varLength,
                                       (jbyte*) environ[i]);
            (*env)->SetByteArrayRegion(env, val, 0, valLength,
                                       (jbyte*) valBeg);
            (*env)->SetObjectArrayElement(env, result, 2*j  , var);
            (*env)->SetObjectArrayElement(env, result, 2*j+1, val);
            (*env)->DeleteLocalRef(env, var);
            (*env)->DeleteLocalRef(env, val);
            j++;
        }
    }
 
    return result;
}

System.out

分析清单 5 可看出,System.out 本质上是一个 PrintStream,而该 PrintStream 在初始化时会读取系统属性”sun.stdout.encoding”,若该值存在则按该值对应的字符集去初始化背后的 OutputStreamWriter;若不存在则按默认字符集初始化 OutputStreamWriter,亦即系统属性”file.encoding”。

清单 5. System.out 相关源码
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
复制代码private static PrintStream newPrintStream(FileOutputStream fos, String enc) {
   if (enc != null) {
        try {
            return new PrintStream(new BufferedOutputStream(fos, 128), true, enc);
        } catch (UnsupportedEncodingException uee) {}
    }
    return new PrintStream(new BufferedOutputStream(fos, 128), true);
}
…
private static void initializeSystemClass() {
    // ...
    props = new Properties();
    initProperties(props);  // initialized by the VM
    //...
    sun.misc.VM.saveAndRemoveProperties(props);
    lineSeparator = props.getProperty("line.separator");
    sun.misc.Version.init();
    FileInputStream fdIn = new FileInputStream(FileDescriptor.in);
    FileOutputStream fdOut = new FileOutputStream(FileDescriptor.out);
    FileOutputStream fdErr = new FileOutputStream(FileDescriptor.err);
    setIn0(new BufferedInputStream(fdIn));
    setOut0(newPrintStream(fdOut, props.getProperty("sun.stdout.encoding")));
    setErr0(newPrintStream(fdErr, props.getProperty("sun.stderr.encoding")));
     
}
 
private PrintStream(boolean autoFlush, OutputStream out) {
    super(out);
    this.autoFlush = autoFlush;
    this.charOut = new OutputStreamWriter(this);
    this.textOut = new BufferedWriter(charOut);
}
private PrintStream(boolean autoFlush, OutputStream out, Charset charset) {
    super(out);
    this.autoFlush = autoFlush;
    this.charOut = new OutputStreamWriter(this, charset);
    this.textOut = new BufferedWriter(charOut);
}
…
public PrintStream(OutputStream out, boolean autoFlush) {
    this(autoFlush, requireNonNull(out, "Null output stream"));
}
public PrintStream(OutputStream out, boolean autoFlush, String encoding)
    throws UnsupportedEncodingException
{
    this(autoFlush,
         requireNonNull(out, "Null output stream"),
         toCharset(encoding));
}

由此可见 System.out 也依赖外部编码,对应地可能会导致以下两种情况的乱码。

  • 外部编码对应的字符集不能涵盖将要输出的字符串,比如在添加了参数-Dsun.stdout.encoding=cp1252 时尝试通过 System.out.println 输出”中文”。
  • 控制台用于解码的字符集与 JVM 外部编码不一致,比如在进行 Eclipse 运行配置时在参数设定页添加了参数-Dsun.stdout.encoding=GBK(控制字符串输出时的编码格式),但在通用设置页却设定编码为 UTF-8(控制显示字符串时的解码格式)。此时通过 System.out.println 输出”中文”时虽然输出的字节数组(以 GBK 编码)是正确的,但是控制台按 UTF-8 解码就会出现乱码。

定位乱码根源的最佳实践

  • 综上,在定位乱码根源前,首先要搞清楚乱码的本质。简化起见,在本文语境中,JVM 可以看做字符串的再加工者,而用于显示的控制台(或文件阅读器)可以简单看做消费者。JVM 生产线的原料是来自文件网络等媒介的各种输入流,只有 JVM 知晓(即指定)该输入流采用的编码方式(即外部编码)才能在输入时将流转换为字符串在 JVM 内部的表现形式;同样在产出时也要选择合适的编码将字符串装配为字节/字符流输出。紧接着显示控制台等消费者也需要采用 JVM 输出时使用的编码去解码该字节/字符流才能保证不出现乱码。

因此在出现乱码时,首先要定位乱码的初始位置,然后再看与该处交互的输入输出采用的编码是否一致,此处要特别注意默认编码对 JVM 编译运行的影响,必要时可以研究 JDK 源码以深入分析一些隐晦的输入输出函数。

总结

本文从一个实际项目中遇到的乱码问题入手,详细描述了该问题的解决过程;继而通过对该问题根源的深入剖析,结合源码和实例从外部编码的角度深入解读了 Java 乱码问题。希望本文能对读者深入理解和解决 Java 乱码问题提供帮助。

参考资源 (resources)

  • 参考 维基百科 ,查看代码页的相关信息。
  • 查阅 JDK Bug
    System
    ,详细了解 System.getenv 的当前实现导致的问题。
  • 访问 openJDK ,下载并查看 JDK 相关源码。
  • 查看文章”深入分析 web 请求响应中的编码问题“,了解更多与 web 有关的乱码问题的成因分析与解决方案。

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

1…909910911…956

开发者博客

9558 日志
1953 标签
RSS
© 2025 开发者博客
本站总访问量次
由 Hexo 强力驱动
|
主题 — NexT.Muse v5.1.4
0%