本文转载自微信公众号《味姐》,作者杨沟姐2号。转载本文请联系味姐公众号。常年沉浸在互联网高并发中的同学在写代码的时候会有一些约定俗成的规则:不要把请求拆分成10个1秒的请求,不要做一个5秒的请求;而是将对象拆分为1000个10KB的对象,并尽量避免生成1MB的对象。为什么?是害怕变大。“大对象”是一个广义的概念。它可能存储在JVM中,可能在网络上传输,也可能存在于数据库中。为什么大对象会影响我们的应用程序性能?有以下三个原因。大对象占用大量资源,垃圾收集器需要花费一些精力来回收它们;不同设备之间交换大对象,会消耗网络流量和昂贵的I/O;对大对象的解析和处理操作是耗时的,如果对象责任不集中,会承担额外的性能开销。接下来xjjdog会从数据的结构维度和时间维度,逐步看一些对象变小,聚焦操作的策略。1、String的substring方法我们都知道String在Java中是不可变的,如果改变其中的内容,就会生成一个新的字符串。如果我们想使用字符串中的部分数据,可以使用substring方法。如图,当我们需要一个子串。substring生成一个新的字符串,由构造函数的Arrays.copyOfRange函数构造。这个功能在JDK7之后是没有问题的,但是在JDK6中,有内存泄漏的风险。我们可以研究这个案例,看看大对象的复用可能带来的问题。这是我从官方JDK截取的截图。可以看出,它在创建子字符串时,并没有只复制需要的对象,而是引用了整个值。如果原始字符串比较大,即使不再使用内存也不会释放。比如一篇文章的内容可能有好几MB,我们只需要里面的摘要信息,一定不能维护整个大对象。Stringcontent=dao.getArticle(id);Stringsummary=content.substring(0,100);articles.put(id,summary);这个供我们参考。如果你创建一个比较大的对象,并根据这个对象生成一些其他的信息。这个时候一定要记得去掉和这个大对象的引用关系。2、集合大对象的扩容对象扩容是Java中普遍存在的现象。如StringBuilder、StringBuffer、HashMap、ArrayList等。简单来说,Java集合,包括List、Set、Queue、Map等,都无法控制其中的数据。当产能不足时,就会有扩容操作。我们先来看StringBuilder的展开代码。voidexpandCapacity(intminimumCapacity){intnewCapacity=value.length*2+2;if(newCapacity-minimumCapacity<0)newCapacity=minimumCapacity;if(newCapacity<0){if(minimumCapacity<0)//overflowthrownewOutOfMemoryError();newCapacity=Integer.MAX_VALUE;}value=Arrays.copyOf(value,newCapacity);}当容量不够时,内存会翻倍,使用Arrays.copyOf复制源数据。下面是HashMap的扩容代码,扩容后大小翻倍。它的扩张动作要复杂得多。除了负载因子的影响外,还需要对原始数据进行重新哈希。由于不能使用原生的Arrays.copy方法,速度会很慢。voidaddEntry(inthash,Kkey,Vvalue,intbucketIndex){if((size>=threshold)&&(null!=table[bucketIndex])){resize(2*table.length);hash=(null!=key)?hash(关键):0;bucketIndex=indexFor(hash,table.length);}createEntry(hash,key,value,bucketIndex);}voidresize(intnewCapacity){Entry[]oldTable=table;intoldCapacity=oldTable.length;if(oldCapacity==MAXIMUM_CAPACITY){threshold=Integer.MAX_VALUE;return;}Entry[]newTable=newEntry[newCapacity];transfer(newTable,initHashSeedAsNeeded(newCapacity));表=newTable;threshold=(int)Math.min(newCapacity*loadFactor,MAXIMUM_CAPACITY+1);}List的代码可以自己查,也是阻塞的。扩展策略是原来长度的1.5倍。由于集合在代码中的使用非常频繁,如果知道数据项的具体上限,不妨设置一个合理的初始大小。比如HashMap需要1024个元素,需要7次扩容,会影响应用的性能。但是需要注意的是,对于像HashMap这种加载因子(0.75)的集合,初始化大小=需要的个数/加载因子+1,如果对底层结构不是很清楚,还不如保持默认.3.保持合适的对象粒度。我曾经遇到过一个并发度非常高的业务系统,需要频繁使用用户基础数据。由于用户的基本信息存储在另一个服务中,因此每次使用用户的基本信息时,都需要进行一次网络交互。更让人难以接受的是,即使只需要用户的性别属性,也需要查询拉取所有的用户信息。为了加快数据的查询速度,首先将数据缓存起来,放到redis中。查询性能有了很大的提升,但是每次查询还是有很多冗余数据。原来的rediskey是这样设计的。type:stringkey:user_${userid}value:json这种设计有两个问题:(1)查询某个字段的值,需要查询所有的json数据,自己解析。(2)更新其中一个字段的值需要更新整个json串,开销很大。对于这种大粒度的JSON信息,可以通过分散的方式进行优化,让每次更新和查询都有一个聚焦的目标。接下来redis中的数据设计如下,使用hash结构代替json结构:type:hashkey:user_${userid}value:{sex:f,id:1223,age:23}这样,我们使用hget命令,或hmget命令,您可以获得所需的数据并加快信息的流动。4、Bitmap能否进一步优化对象大小?例如,我们的系统经常使用用户的性别数据来分发一些礼物,推荐一些异性朋友,定期循环用户做一些清洁动作等。或者,存储一些用户状态信息,比如是否在线,是否签到,最近有没有发消息等,统计活跃用户等。yes和no这两个值的操作可以使用Bitmap的结构进行压缩。如代码所示,通过判断int中的每一位,可以保存32个布尔值!inta=0b0001_0001_1111_1101_1001_0001_1111_1101;Bitmap是一种用Bit来记录的数据结构,里面存储的数据不是0就是1。Java中相关的结构类是java.util.BitSet。BitSet底层是使用长数组实现的,所以它的最小容量是64.1亿个布尔值,只需要128MB的内存。下面是一个判断用户性别的逻辑,占用256MB,可以覆盖10亿长度的id。staticBitSetmissSet=newBitSet(010_000_000_000);staticBitSetsexSet=newBitSet(010_000_000_000);StringgetSex(intuserId){booleannotMiss=missSet.get(userId);if(!notMiss){//lazyfetchStringlazySex=dao.getSex(userId);missSet.set(,true);sexSet.set(userId,"female".equals(lazySex));}returnsexSet.get(userId)?"female":"male";}这些数据都放在堆内存中,还是太大了。幸运的是,Redis还支持Bitmap结构。如果内存有压力,我们可以把这个结构体放到Redis中,判断逻辑类似。还有很多这样的问题:给定一台1GB内存的机器,60亿条int数据,如何快速判断哪些数据是重复的?大家可以类比一下。Bitmap是一个比较底层的结构,在它上面有一个叫做BloomFilter的结构。布隆过滤器可以判断一个值是否不存在,或者可能存在。与Bitmap相比,它多了一层哈希算法。由于是hash算法,会存在冲突,所以可能有多个值落在同一个位上。Guava中有一个BloomFilter类,可以方便的实现相关功能。5.冷热数据分离上面的优化方法本质上是一种变大对象为小对象的方法。软件设计中有很多类似的想法。像一篇新发表的文章,摘要数据使用频率高,没必要去查询文章的全部内容;用户的feed信息只需要保证可见信息的速度,完整的信息存储在大存储中较慢的部分。除了横向的结构纬度,数据还有纵向的时间维度。优化时间维度最有效的方法是冷热分离。所谓热数据,就是离用户很近,使用频率很高的数据,而冷数据,就是访问频率很低,很旧的数据。如果在千万级的数据表上运行同样的复杂SQL,前者的效果肯定会很差。所以,虽然你的系统上线时速度很快,但随着数据量的增加,它会随着时间的推移逐渐变慢。冷热分离就是把数据分成两部分。如图所示,对于一些比较耗时的统计操作,一般会保留全量的数据。下面简单介绍一下冷热分离的三种方案。(1)数据双写。将所有对冷热存储的插入、更新、删除操作放在一个统一的事务中。由于热存储(如MySQL)和冷存储(如Hbase)的类型不同,这个事务很可能是分布式事务。在项目初期,这种方式是可行的,但是如果是改造一些遗留系统,分布式事务基本上是改不了的。我通常直接废弃这个解决方案。(2)编写MQ分布。通过MQ的发布和订阅功能,在进行数据操作时,并不存储在数据库中,而是发送给MQ。分别启动消费流程,将MQ中的数据分别落入冷库和热库。这样改造后的业务,逻辑非常清晰,结构优雅。一个结构比较清晰,顺序要求不高的系统,比如一个订单,可以使用MQ的分发方式。但是如果你的数据库实体量很大的话,你就得考虑这种方式的程序复杂度了。(3)使用binlog同步对于MySQL,可以使用Binlog来同步。使用Canal组件,可以持续获取最新的Binlog数据,结合MQ,可以将数据同步到其他数据源。End关于大对象,我们可以再举两个例子。和我们常用的数据库索引一样,也是对数据的一种重组和加速。B+树可以有效减少数据库与磁盘的交互次数。它采用类似B+树的数据结构,将最常用的数据进行索引,存储在有限的存储空间中。还有RPC中常用的序列化。有些服务是使用SOAP协议的WebService,这是一种基于XML的协议,传输大内容速度慢,效率低。目前的web服务大多使用json数据进行交互,json比SOAP效率更高。另外大家应该听说过Google的protobuf。因为是二进制协议,并且压缩数据,所以性能非常优越。protobuf对数据进行压缩后,大小仅为json的1/10,xml的1/20,性能却提升了5-100倍。protobuf的设计值得借鉴。它通过tag|leng|value这三段处理数据非常紧凑,解析和传输速度极快。对于大对象,我们有两种方法:结构维度优化和时间维度优化。从结构纬度上看,通过将对象划分成合适的粒度,可以将操作集中在小的数据结构上,降低时间处理成本;通过压缩和转换对象,或提取热点数据,可以避免大对象的存储和传输成本。在时间纬度上,常用的数据可以通过冷热分离的方式存储在高速设备中,减少数据处理的收集,加快处理速度。作者简介:品味小姐姐(xjjdog),一个不允许程序员走弯路的公众号。专注于基础架构和Linux。十年架构,每天百亿流量,与你探讨高并发世界,给你不一样的滋味。我的个人微信xjjdog0,欢迎加好友进一步交流。
