项目中需要提供一个单机计算视频相似度的服务,计算的方式是对视频标题进行分词,提取关键词,然后通过word2vec的方式对关键词进行embedding,最后通过向量累加得到视频的词向量,然后通过某种相似度算法(比如欧式距离)得到视频相似度。这个服务要求5ms返回,可行性预研阶段需要估算响应时间能否达到要求,需要多少台机器支撑每天50亿的请求量。这里面有两个关键内容需要估算,一个是响应时间是多少,能否达到5ms的要求;另一个是每个服务需要多少存储空间(从而估算一台64G的机器能跑几个实例,这里假定一个实例能支撑的QPS是已知的)。这个服务中占用内存最大的是存储80万的词向量,时间消耗比较大的地方也是词向量的读取。
每次计算视频相似度需要大概查询150次词向量(50个视频乘以3个关键词),大量的时间都花在词向量查询,如果使用分布式缓存进行存储词向量,都不能够达到预期,所以初步设计是存在内存中,使用HashMap进行存储,Map的key是关键词,value是一个float数组。我们从生成好的80万词向量文件中随机抽取了10%的数据约81000行,样例文件大小225M,数据样例如下:
现在需要对程序使用内存进行预估,来决定申请机器的台数。这块使用了两种方法来预估内存:第一种通过使用java.lang.Runtime.freeMemory()方法来进行粗略的估计,在读取文件到Map前进行调用一次freeMemory方法,然后在数据填充进Map后再调用一次freeMemory,使用第一次的值减去第二次值,就可以得到HashMap近似的内存大小。注意使用这样方法进行预估时,一定要将运行jvm虚拟机-Xms 与-Xmx设置相等。具体代码和运行结果如下:
计算结果HashMap对象占用内存为83M,生成HasMap对象总共占用了约324M内存。
第二种方法是将生成好的HashMap对象序列化到本地生成文件,查看文件大小近似估计HashMap所占的空间,生成文件大小为95M。
通过上述两种方法测试分析得到,加载10%的数据到HashMap对象时大约需要400M的内存空间,实际保存HashMap需要约100M的内存,依次类推加载所有数据HashMap不超过1.5G,加载HashMap使用内存到的总内存不会超过2G,加上相关性计算及多线程访问需要的内存大约每个实例预估使用4G内存。由于HashMap是线程不安全的,所以每次重新加载数据时需要先临时生成一个HashMap对象将新的数据加载进临时对象中,加载完毕之后将引用对象指向临时生成的HashMap,所以在重新加载对象时使用的内存是实际存储HashMap空间的2倍,也就是一个实例最终需要6G的内存,其中有2G的内存在平时属于空闲状态只有在数据重新加载时才会使用。这样实现很大程度会浪费很多内存空间,增加机器台数,增加了投入成本。实现代码如下:因为是HashMap是线程不安全,所以采用了以上实现。如果使用线程安全的ConcurrentHashMap在数据重新加载不需要以上操作节省一个Map存储的内存。使用同样代码对ConcurrentHashMap进行测试,得到ConcurrentHashMap存储所占用的内存约为84M,生成的序列化文件大小为95.3M与HashMap占用内存空间基本一致。现在唯一的问题是确定ConcurrentHashMap读取效率,是否能满足要求,网上查找资料时发现没有比较HashMap和ConcurrentHashMap在多线程下get操作的耗时对比,所以做了以下实验。
CPU:Intel(R) Xeon(R)CPU E5-2620 0 @ 2.00GHz, 2个物理cpu,每个cpu包含6核心,每个核心4个线程
Jdk1.7版本:jdk1.7.0_80
Jdk1.6版本:jdk1.6.0_45
使用相同的词向量文件分别构造HashMap与ConcurrentHashMap,分别使用1个,12个,24个,48个线程,每个线程循环进行1000000次的get操作达到模拟高并发下的查询,记录测试时间;同样的程序使用jdk1.6与jdk1.7分别进行测试比较耗时。测试代码如下:
测试结果如下表所示(每个线程请求1000000次):
|
jdk1.7 |
jdk1.6 |
||
线程数 |
HashMap |
ConcurrentHashMap |
HashMap |
ConcurrentHashMap |
1 |
42ms |
48ms |
46ms |
62ms |
12 |
52ms |
62ms |
62ms |
76ms |
24 |
70ms |
84ms |
83ms |
96ms |
48 |
137ms |
159ms |
139ms |
174ms |
使用jdk1.7测试结果来看ConcurrentHashMap比HashMap慢20%, HashMap的get操作需要42ns,ConcurrentHashMap的get操作需要48ns。从jdk两个版本对比来看,jdk1.7比jdk1.6大约快20%左右。从测试结果来看,使用ConcurrentHashMap代替HashMap完全没有问题。
ConcurrenHashMap使用了锁分段技术,将Hash表默认分为16个段(桶),每一个段上加一把锁,如果一个段被锁不会影响其他段的线程访问。ConcurrenHashMap 具体是由Segment数组和HashEntry数组构成的。每个Segment都可以理解为是一个HashTable,Segment包含一个HashEntry数组,HashEntry是一个链表结构,每一个Segment守护一个HashEntry数组,要对HashEntry数组操作时必须首先取得Segment的锁,才能更改HashEntry数组中的数据。读取数据是先需要找到数据所在的Segment,然后再在HashEntry数组中找到具体的HashEntry对象,然后从头开始访问链表,找到相同key的返回该对象的值,找不到查next对象的key值是否相同,一直查询到链表结束。从Jdk1.7 ConcurrentHashMap get方法源码可以看出CocurrentHashMap比HashMap进行get操作时,多进行了一次Hash来得到Segment,得到Segment后的操作与HashMap的get方法基本一致,通过一次hash找到HashEntry在数组中的位置,然后从头遍历该链表。CocurrentHashMap在取得Segment和HashEntry时使用了sun.misc.Unsafe类中提供的方法,Unsafe类提供的硬件级别的原子操作,调用操作系统底层提高性能。jdk1.7 CocurrentHashMap与jdk1.6的访问速度差异主要在于,jdk1.6如果在HashEntry中找多对用的key的值,如果值为null会加锁再读一次,而jdk1.7大量使用了Unsafe类提供的方法来提高性能。jdk1.7 CocurrentHashMap与jdk1.6的访问速度差异主要在于,jdk1.6如果在HashEntry中找多对用的key的值,如果值为null会加锁再读一次,而jdk1.7大量使用了Unsafe类提供的方法来提高性能。
参考资料:
http://ifeve.com/sun-misc-unsafe/
http://www.blogjava.net/stevenjohn/archive/2015/03/15/423475.html
http://www.infoq.com/cn/articles/ConcurrentHashMap
http://www.cnblogs.com/ITtangtang/p/3948786.html