近两年接触Java开发,负责移动开发框架的服务端设计和实现,其中免不了挖些大大小小的坑。当项目落地实施或者做性能分析才发现,我们在编写Java代码时候多了解我们使用到的数据结构或工具的原理,就能大大提...
字符串优化
1、String对象特点
1.1 不变性
不变性指String对象一旦生成,不能再对它进行改变。该特性采用并行设计的不变模式,即一个对象状态在对象被创建后不再发生任何改变,这使得该对象被多线程共享并频繁访问时,可以省略同步和锁等待时间,大幅提高系统性能。
以下是String源码:
<span style="font-size:12px;">public final class String implements java.io.Serializable, Comparable<>, CharSequence {
private final char value[];
private final int offset;
private final int count;
}</span>
该不变模式中,final关键字起到了重要作用。对class的final定义保证该类没有子类,对属性的final定义确保数据只能在对象被构造时赋值一次,就永远不会改变。
1.2 针对常量池的优化
该特征指当两个String对象拥有相同值时,他们只引用常量池中的同一个拷贝。当该字符串反复出现时,该特征可以大幅度节省内存空间。
2、优化建议
String对象是Java中重要的数据类型,大部分方法都包含大量String基础操作。针对上述字符串的特征,结合平时在写框架代码中踩过的大坑,总结出以下几种字符串优化建议。
2.1 subString()内存溢出
在Java中我们无须关心内存的释放,JVM提供了内存管理机制,有垃圾回收器帮助回收不需要的对象。但实际中一些不当的使用仍然会导致一系列的内存问题,常见的就是内存泄漏和内存溢出。
我们经常会用到String类的subString方法,但在JDK1.6中该方法的使用需要更加谨慎,比如如下代码:
<span style="font-size:12px;">public static void main(String[] args) {
List<String> handler = new ArrayList<String>();
for (int i = 0;i < 10000;i++) {
BigData bd = new BigData();
// ImprovedBigData ibd = new ImprovedBigData();
handler.add(bd.getSubString(0,5));
bd = null;
}
}
static class BigData {
private String str = new String(new char[100000]);
public String getSubString(int begin, int end)
return str.substring(begin, end);
}</span>
代码很简单,循环构造BigData对象,将该对象属性str(很大)的前五个字符取出存入列表handler,然后将该对象置空。但是执行程序却以Out of Memory退出,但是换成JDK7,程序却能正常执行,也就是说在JDK6环境下,出现了内存溢出情况,通过查看subString()源码,如下所示,最后new出来的String同样会指向原有的大数组,只是改变了offset和count,这就导致即使将之前对象置为空,本意想释放str所占空间,但是返回的new String同样指向该堆内存,当堆内存吃紧触发GC时不会自动回收该段内存,导致内存泄露。
<span style="font-size:12px;">public String substring(int beginIndex, int endIndex) {
if (beginIndex < 0)
throw new StringIndexOutOfBoundsException(beginIndex);
if (endIndex > count)
throw new StringIndexOutOfBoundsException(endIndex);
return ((beginIndex == 0) && (endIndex == count)) ? this :
new String(offset + beginIndex, endIndex - beginIndex, value);
}</span>
在JDK7中改进了subString()的实现,它实际是为截取的子字符串在堆中创建了一个新的char数组用于保存子字符串的字符。代码如下
public String substring(int beginIndex, int endIndex) {
return ((beginIndex == 0) && (endIndex == value.length)) ? this
: new String(value, beginIndex, subLen);
}
public String(char value[], int offset, int count) {
this.value = Arrays.copyOfRange(value, offset, offset+count);
}
可以发现是去为子字符串创建了一个新的char数组去存储子字符串中的字符。这样子字符串和父字符串也就没有什么必然的联系了,当父字符串的引用失效的时候,GC就会适时的回收父字符串占用的内存空间。
2.2 高效使用字符串分割、查找
字符串分割和查找也是字符串处理中常用方法之一。字符串分割将一个原始字符串根据分隔符,切割成一组小字符串。String对象的split()方法便实现了此功能。该功能非常强大,还支持对正则表达式的支持,比如字符串“a,b:c;d”,分别使用分号、逗号、冒号分割开来,使用代码:"a,b:c;d".split("[;|:|,]")即可分开成a、b、c、d四个字串。但如果就简单字符串分割,它的性能却不尽如意。
效率更高的StringTokenizer类是JDK中提供的专门处理字符串分割字串工具类,虽然只支持单一的字符串,但相对于split(),它的效率却高不少。以下是二者性能比较:
StringBuffer sb = new StringBuffer();
for (int i = 0; i < 1000; i++)
sb.append(i).append(";");
for (int i = 0; i < 10000; i++)
sb.toString().split(";");
// 打印处理时间
StringTokenizer st = new StringTokenizer(orgStr, ";");
for (int i = 0; i < 10000; i++) {
while(st.hasMoreTokens())
st.nextToken();
st = new StringTokenizer(orgStr, ";");
}// 打印处理时间
Split()方法运行时间花费847ms,StringTokenizer类运行时间534ms,所以在能够使用StringTokenizer的模块中,就没必要使用split()。
其实还有更优化的字符串分割方式,那就是String类的indexOf(),通过寻找分隔符位置并截取子字符串,运行时间仅需200ms左右,性能比StringTokenizer要高不少。
2.3 StringBuilder、StringBuffer的选择
由于String对象是不可变对象,在需要对字符串进行修改、连接、替换时,String对象总会生成新对象,处理性能降低。相对来说,JDK专门提供的用于创建和修改字符串的工具StringBuffer及StringBuilder类是个比较好选择。
首先来看一个大String对象的累加操作,通过执行一万次循环,在同等条件下,直接相加的字符串操作比用StringBuilder的实现慢了1000倍。
<pre name="code" class="java"> for (int i = 0; i < 10000; i++)
str = str + i;
StringBuilder sb = new StringBuilder();
for (int i = 0; i < 10000; i++)
sb.append(i);
通过反编译代码显示,String对象在做累加操作时会在编译期将代码优化成StringBuilder的实现,但编译器并没有做出足够的判断,相对于StringBuilder只需维护一个对象实例,String对象每次循环累加都构造一个新的StringBuilder实例,从而大大降低系统性能,编译后代码如下:
7 new java.lang.StringBuilder [56]
18 iload_2 [i]
19 invokevirtual ava.lang.StringBuilder.append(int):java.lang.StringBuilder [67]
22 invokevirtual java.lang.StringBuilder.toString() : java.lang.String [71]
Line numbers:
[pc: 2, line: 22]
[pc: 7, line: 23]
StringBuilder和StringBuffer是一对孪生兄弟,它们都实现了对AbstractStringBuilder抽象类,拥有几乎相同的对外借口,二者最大的不同在于StringBuffer对几乎所有的方法都做了同步,而StringBuilder并没有做任何同步。
总之,在无需考虑线程安全情况下,可以使用性能相对好的StringBuilder,但若对线程安全有要求,只能选择StringBuffer。
并行开发与优化
1、慎用同步(synchronized)
同步关键字synchronized是并行Java系统中最常用同步方法之一,但过多的同步操作,会引起更多的锁竞争,从而严重影响系统的处理能力,如果没有完全掌握关键字synchronized的原理,也很容易挖坑造成过多无谓的锁竞争。以下是我挖过的坑:
public synchronized void getRule(String ruleId) {
System.out.println("进入同步方法1");
}
public void getRule2(String ruleId) {
synchronized (this)
System.out.println("进入同步方法2");
}
上述两个方法,一个是在方法名加锁,一个是在方法内部加锁,目的是防止两个或多个线程同时执行一个方法。表面上互不影响的两个方法,在上述同步后产生了紧密关系,二者是对同一个对象加的锁,即该类对象,很多时候我们系统采用的是单例模式,如果两个线程执行上述两个方法,竞争的是同一个锁。
所以一个类里如果出现两个关键字synchronized以上,就得注意它们锁定的对象是否相同,可以通过增加简单锁变量来分离。
2、强大的可重入锁(ReentrantLock)
ReentrantLock称为可重入锁,它可中断、可定时,在高并发的情况下,它比synchronized有明显的性能优势。ReentrantLock锁提供以下重要的方法:
public void lock() {sync.lock();} //获得锁,如果已占用,则等待
public void lockInterruptibly() {sync.acquireInterruptibly(1);} //获得锁,但优先响应中断
public boolean tryLock() {return sync.nonfairTryAcquire(1);} //尝试获得锁,立即返回结果
public boolean tryLock(long timeout, TimeUnit unit) { //给定时间内尝试获得锁
return sync.tryAcquireNanos(1, unit.toNanos(timeout));
}
上述丰富、灵活的锁控制功能在锁竞争激烈的情况下,有助于应用程序在应用层根据合理任务分配来避免锁竞争,以提高应用程序性能。
3、ThreadLocal与线程池
ThreadLocal虽然也属于一种多线程并发访问变量的解决方案,但它与synchronized等加锁方式有本质区别,ThreadLocal完全不依赖于锁机制,而是使用以空间换时间的手段,为每个线程提供变量的独立副本,以保障线程安全。在高并发或者锁竞争激烈的情况下,使用ThreadLocal可以很大程度上减少锁竞争。它提供的接口很简单:
public T get() {} //将此线程局部变量当前线程副本中的值设成指定值
public void set(T value) {} //返回此线程局部变量的当前线程副本中的值
public void remove() {} //移除此线程局部变量当前线程的值
这就需要确保ThreadLocal内部存储的是与该线程相关的数据,不能存储一些共享对象或者静态变量等。那么问题来了,我们大部分应用系统都采用线程池技术响应用户请求,势必导致某个线程不会只存活于单个用户请求,这很容易导致内存溢出。
前几天手机银行就出现了该问题,异常信息通过ThreadLocal来保存,在程序出口做统一处理后返回前端,但发现如果用户出现第二次或以后的异常,报的都是第一次异常信息。甚至会出现第一次异常信息跟本人无关。究其原因,一是ThreadLocal不会主动删除已保存的局部变量,除非该线程被销毁,没有任何引用指向该变量,它占得内存才会被GC释放;二、为了避免上一次请求的局部变量影响线程的下一次响应,在每次请求处理完成后,显示调用ThreadLocal的remove()方法或set(null),清除数据。
填坑技巧
1、提前编译正则表达式
字符串操作在Java中算是开销较大的操作,再加上正则表达式的匹配操作,开销成倍增加。Java.util.regex是用正则表达式所定之的模式来对字符串进行匹配工作的类库包,包括Pattern和Matcher两个类。在Firefly框架中,每个交易都会配置自有跨站规则,每个跨站规则都通过正则表达式来配置。如果每次交易请求过来,频繁读取配置并编译成Pattern,势必大大影响应用系统响应能力。因此我们预先编译好每个交易的Pattern并缓存到内存中,每次请求过来只需提取Pattern做正则匹配即可。
2、Exception优化
Java提供try/catch来方便用户捕捉异常进行处理,但每次new Exception()会构建一个异常堆栈路径,非常耗费时间和空间,尤其在递归调用时候,比普通对象要慢很多。所以我们在使用try/catch时,只进行意外或错误场景的处理,不能将异常用于流程控制、终止循环等,也可以通过重写Exception类的fillInStackTrace方法避免过长堆栈路径的生成。
3、减少new关键字
在Java程序中,对象的创建和销毁是一个重量级的操作,会增加系统性能开销降低系统性能,而GC并不由应用系统控制,已经销毁的对象很难短期内释放内存,增加服务器开销。对于单例模式来说,不仅方便多线程调用该实例,更主要是减小了频繁创建带来的系统消耗。