[CSDN] 那些年挖过的Java坑

发表于:4天前  阅读量:14904

摘要

近两年接触Java开发,负责移动开发框架的服务端设计和实现,其中免不了挖些大大小小的坑。当项目落地实施或者做性能分析才发现,我们在编写Java代码时候多了解我们使用到的数据结构或工具的原理,就能大大提...

字符串优化

1String对象特点

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关键字起到了重要作用。对classfinal定义保证该类没有子类,对属性的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同样会指向原有的大数组,只是改变了offsetcount,这就导致即使将之前对象置为空,本意想释放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("[;|:|,]")即可分开成abcd四个字串。但如果就简单字符串分割,它的性能却不尽如意。

       效率更高的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()方法运行时间花费847msStringTokenizer类运行时间534ms,所以在能够使用StringTokenizer的模块中,就没必要使用split()

       其实还有更优化的字符串分割方式,那就是String类的indexOf(),通过寻找分隔符位置并截取子字符串,运行时间仅需200ms左右,性能比StringTokenizer要高不少。

2.3 StringBuilderStringBuffer的选择

       由于String对象是不可变对象,在需要对字符串进行修改、连接、替换时,String对象总会生成新对象,处理性能降低。相对来说,JDK专门提供的用于创建和修改字符串的工具StringBufferStringBuilder类是个比较好选择。

       首先来看一个大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]

       StringBuilderStringBuffer是一对孪生兄弟,它们都实现了对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释放;二、为了避免上一次请求的局部变量影响线程的下一次响应,在每次请求处理完成后,显示调用ThreadLocalremove()方法或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并不由应用系统控制,已经销毁的对象很难短期内释放内存,增加服务器开销。对于单例模式来说,不仅方便多线程调用该实例,更主要是减小了频繁创建带来的系统消耗。

   

关键词:
渝ICP备16002246号 Copyright © 2017. Singee77.com