java代码优化
架构师在优化java系统性能的过程中,可以做出很多重要决策以全面提升系统的性能,例如使用高版本的jdk,引入Redis或redis+JVM缓存,甚至考虑JVM缓存分成多层,比如热点缓存+普通数据缓存等。
在数据上可以考虑数据库分库分表或者一主多从,考虑引入中间件提供表的路由,引入分布式管理器或者状态机保证事务一致性。对于大数据查询,可以考虑Elasticsearch或Hive大数据系统建立统一的数据查询接口,架构师需要考虑如何把数据库的数据同步带大数据系统,以及Redis缓存中。
系统交互上使用消息中间件实现异步通信,也可以使用RPC进行远程调用,架构师还可以把单体系统服务改成微服务系统,这种架构的改变“牵一发而动全身”。
优化系统的方式主要是优化自己或者他人写的代码。代码是系统的基石,没有良好的代码,系统架构不牢固。
可优化的代码
来看一段优化前的代码:
public Map buildArea(List<Area> areas){
if(areas.isEmpty()){
return new HashMap();
}
}
Map<String,Area> map =new HashMap();
for(Area area :areas){
String key=area.getProvinceId()+"#" +area.getCityId();
map.put(key,area);
}
return map;
当判断这段代码每次请求都被调用说明这段代码需要进行优化、方法返回值是个Map,非常不容易阅读,因为当其他代码阅读者看到方法签名是并不清楚Key和Value的类型,必须阅读代码才知道,因此将方法签名改成如下内容:
public Map<String,Area> buildArea(List areas)
Map中的key是字符串类型,使用者必须了解方法的实现才知道如何使用Key,这里有两种方法改善方法。
一是在方法的Javadoc注释说明Key的格式:
/**
*构建一个地址Map
*@param areas 初始化地址列表
*@return Key的格式是省编号+“#”+市编号
*/
public Map<String,Area> bulidArea(List<Area> areas)
二是在使用buildArea方法是通过一个有意义的名称来描述,例如:
Map<String,Area> provinceCityMap=buildArea(areas);
这两种方法都能帮助阅读者明白bulidarea返回值的含义,但仍然有一定的阅读负担,使用者不得不再次阅读注释来了解Map的含义,注释很少跟随代码的改动很少,而重构往往遗漏了注释,如果Key值得构成发生了变化,比如包含城镇编号:
String key=area.getProvinceId()+"#"+areaa.getCityId()+"#"+area.getTownId();
以String来描述对象,这个改动对于项目来说是一个巨大的灾难,所有使用buildArea的地方都必须改动,如服务果忘记了,则会造成损失,导致服务不可用。
这段代码从可读性和性能上来说,最大的问题时Key值得类型是String, 从可读性上来说,字符串让人难以理解其构成,即使方法签名从Map变成Map<String,Area>,仍然难以理解,如果想用字符串来表达一个对象,那么嗨不如直接使用一个对象,我们可以创建一个新的对象来描述“需求”:
@Getter
@Setter
public class CityKey {
private Integer provinceId;
private Integer cityId;
}
这样,代码可以改成如下内容:
public Map<CityKey,Area> buildArea(List<Area> areas)
{
if (areas.isEmpty()){
return new HashMap();
}
Map<CityKey,Area> map=new HashMap<>();
for (Area area: areas) {
CityKey key=new CityKey(area.getprpovinceId(),area.getCityId);
map.put(key,area);
}
}
修改后的代码更容易阅读,还可以改进的地方是CityKey的构造通过Area来生成,可以为Area添加一个buildKey方法:
public class Area{
...
public CityKey buildKey(){
return new CityKey(provinceId,cityId);
}
}
这样CityKey由Area来维护,任何CityKey含义的变更、重构,都不会影响使用它的代码。
在讨论完代码易用性后,读者也许会疑惑,构造一个新的CityKey对象会不会是性能降低呢?其实恰恰相反,之前使用String来构造一个Key的代价相当昂贵,这也是接下来要讨论的内容。
在编译期遇到字符串相加时,都会使用StringBuilder来完成字符串拼接功能,对于如下代码:
area.getProviceId()+"#"+area.getCityId();
在编译后,实际的代码如下:
StringBuilder sb=new StringBuilder();
sb.append(area.getprovinceId());
sb.append("#");
ab.append(area.getCityId());
可以使用jdk的反编译工具javac -p aea.class
下面的这段代码性能问题主要在于整型值转化为字符串时,StringBuilder最终会调用Integer.toString()方法:
public static String toString(int i) {
if (i == Integer.MIN_VALUE)
return "-2147483648";
int size = (i < 0) ? stringSize(-i) + 1 : stringSize(i);
char[] buf = new char[size];
getChars(i, size, buf);
return new String(buf, true);
}
为了将数字转化为字符串,首先需要确定字符串的长度,可以通过
Integer.stringSize()方法获取字符串的长度。
final static int [] sizeTable = { 9, 99, 999, 9999, 99999, 999999, 9999999,
99999999, 999999999, Integer.MAX_VALUE };
// Requires positive x
static int stringSize(int x) {
for (int i=0; ; i++)
if (x <= sizeTable[i])
return i+1;
}
比如数字139需要长度为3的字符串数组,通过循环3次,在i=0和i=1的时候(x<=sizeTable[i])条件不满足,继续循环,当i=2的时候,条件满足,返回i+1,从而确定数字139需要创建一个长度为3的字符串数组。
在获取字符串长度后,需要从内存中分配一个buf 数组,getchars()用于真正转化int类型到字符串,并赋值给buf数组,getChar需要30行代码来完成。
static void getChars(int i, int index, char[] buf) {
int q, r;
int charPos = index;
char sign = 0;
if (i < 0) {
sign = '-';
i = -i;
}
// Generate two digits per iteration
while (i >= 65536) {
q = i / 100;
// really: r = i - (q * 100);
r = i - ((q << 6) + (q << 5) + (q << 2));
i = q;
buf [--charPos] = DigitOnes[r];
buf [--charPos] = DigitTens[r];
}
// Fall thru to fast mode for smaller numbers
// assert(i <= 65536, i);
for (;;) {
q = (i * 52429) >>> (16+3);
r = i - ((q << 3) + (q << 1)); // r = i-(q*10) ...
buf [--charPos] = digits [r];
i = q;
if (i == 0) break;
}
if (sign != 0) {
buf [--charPos] = sign;
}
}
StringBuilder类型获取int对应的字符串后,是否后面就没有什么代码能影响性能了?实际上还有一系列操作才能真正完成字符串的拼接,实现字符串来表达Key的功能。
public AbstractStringBuilder append(String str) {
if (str == null)
return appendNull();
int len = str.length();
ensureCapacityInternal(count + len);
str.getChars(0, len, value, count);
count += len;
return this;
}
ensureCapacityInternal方法用于保证StringBuilder的buf足够长,以容纳str字符串,如果buf不够长,则需要扩容,扩容意味着进一步加大内存块,然后将原有的内容复制到这份内存块中,这也是一个消耗内存的地方。
str.getChars方法也会做一次内存复制,将str的内容复制到新的buf中,至此,一个字符串的拼接真正完成。
当我们需要将一个String对象作为一个Key的时候,会调用StringBuilder.toString方法
@Override
public String toString() {
// Create a copy, don't share the array
return new String(value, 0, count);
}
正如JDK文档的注释Create a copy, don’t sharethe array所描述的,实际上,这也是一个消耗资源的操作, 内部会开批一段空间,并把StringBuilder的char数组内容复制到String中:
public String(char value[], int offset, int count) {
this.value = Arrays.copyOfRange(value, offset, offset+count);
}
通过StringBuilder.append()方法可以发现执行代码还是非常长的 ,我们可以通过PerformanceAreaTest验证一下对象表示Key和用字符串表示Key两种方式的性能。前者吞吐量是后者吞吐量的5倍,如果只计较Key的生成,则对象Key生成的吞吐量是字符串Key生成的吞吐量的50倍。