高并发下的九死一生,一个不小心就掉入万丈深渊

发布时间:2014-09-06 03:18:00作者:左潇龙阅读(3447 )评论(40)

    引言

     

      每次开篇LZ都会先说一下自己最近很忙,所以才没有时间写博客。这次这些话就不多说了,我们直入主题,尽管LZ依旧是非常的繁忙...

      了解LZ的猿友应该都知道,LZ最近弄了一个hbase(不理解hbase的猿友可以把hbase当做与oracle,mysql,sqlserver等一样的数据库,并不影响阅读本文)的大数据平台,或许现在叫平台还有点名不副实,不过它很快就会发展到这个规模,LZ一直坚信着。在建立这个平台的过程中,LZ遇到过各种千奇百怪的问题,在这里LZ就分享一个非常简单,但却很奇葩的问题。

      

    问题来源

     

      问题的来源特别简单,LZ为了迎合模块化开发的思想,做了很多独立的模块,这些模块以jar包的形式协同工作,类似于spring当中的spring-core,spring-beans,spring-context等等。

      在LZ的一个common包中,有这样的一个工具类,代码如下。(备注:LZ为了简单,去掉了很多跟本文无关的代码,但不影响阅读,因为这个类就是一些静态的工具类方法,主要用于处理日期)

    public class DateUtil {
    
        private DateUtil(){}
    
        private static final DateFormat DATE_FORMAT = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
    
        public static Date parse(String date)  throws ParseException {
            return DATE_FORMAT.parse(date);
        }    
    
    }

      这个类特别简单,相信有不少猿友都会觉得这个类没有多大问题。因为这段代码太简单了,当你的项目报错的时候,你很难想到这段代码就是错误的根源。很显然,LZ就在hbase的应用中使用了这个工具类,结果就导致了一个奇葩问题。

      大致描述一下这个工具类使用的场景。LZ的hbase应用接收了来自于其它系统大量的日志信息,并会将这些日志信息存储在hbase当中,其实就是一个简单的日志保存功能。如果单纯从功能上来讲,就是一个简单的curd(增删改查)操作中的c(增)操作。唯一不同的是,由于存储的是来自很多系统的系统日志,webservice日志,mq日志,url访问日志,因此并发量会有点高,至少比LZ平时做的企业应用要高太多太多了。

      这个工具类就是在解析日志信息中的日期字符串(比如日志的发生时间)时报的错,具体的错误信息如下。(备注:以下是真实的报错信息,显示的错误位置与上面的代码不符,不过各位猿友完全可以认为就是上面的方法报的错,因为事实上parseTimestamp这个方法就和上面方法的代码是一样的。)

    java.lang.NumberFormatException: For input string: ""
            at java.lang.NumberFormatException.forInputString(NumberFormatException.java:48)
            at java.lang.Long.parseLong(Long.java:431)
            at java.lang.Long.parseLong(Long.java:468)
            at java.text.DigitList.getLong(DigitList.java:177)
            at java.text.DecimalFormat.parse(DecimalFormat.java:1297)
            at java.text.SimpleDateFormat.subParse(SimpleDateFormat.java:1589)
            at java.text.SimpleDateFormat.parse(SimpleDateFormat.java:1311)
            at java.text.DateFormat.parse(DateFormat.java:335)
            at com.xxxxxxx.core.common.util.DateUtil.parseTimestamp(DateUtil.java:95)
            at com.xxxxxxx.core.common.util.DateUtil.parse(DateUtil.java:84)
            at com.xxxxxxx.hbase.generator.LogRowKeyGenerator.generate(LogRowKeyGenerator.java:21)
            ... 22 more

      

    问题分析

     

      看到这个错误,大部分老道一点的程序猿一眼就能定位问题,肯定是传过来的日期格式不对,所以导致在解析的时候出错了。

      LZ自认为还算老道吧(小小的自夸一下),自然也很快的意识到了问题的根源。于是最简单的方式,调试一下代码,看传过来的日志信息到底是什么样子。

      LZ在catch块里加入了断点,当报出这个错误的时候,会进入调试(只能在catch块里捕捉,因为这个异常是时而出现的,而且毫无规律)。但是结果很意外,LZ仔细且认真的看了传送过来的日志信息,日期格式却明明是正确的。这时候LZ就傻眼了,格式明明是正确的,解析怎么可能报错呢?

      LZ不相信这种奇怪的问题,于是LZ采用最简单的办法,希望印证心中所想,将代码改成如下的样子。

    public class DateUtil {
    
        private DateUtil(){}
    
        private static final DateFormat DATE_FORMAT = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
    
        public static Date parse(String date)  throws ParseException {
            System.out.println("date:" + date);
            return DATE_FORMAT.parse(date);
        }    
    
    }

      这是最简单粗暴的调试方式,也是LZ初入程序猿这个职业时经常用的办法。可惜,结果依然不如人意,当偶尔出现异常时,打印出来的日志格式依旧是正确的。事实上,各个系统使用的客户端也是LZ开发的,也不应该出现日期格式错误的问题。

      这到底怎么回事呢?事实就是,日期格式是正确的,但就是解析失败!

      

    水落石出

     

      LZ在想不明白一个问题的时候,习惯出来抽根烟,透透风。不过不得不说,这个办法真的好使,LZ一瞬间灵感就袭脑了。

      这么奇葩的问题,也只有高并发可以解释了!

      于是二话不说,扔掉烟头,LZ就回到电脑前打开了SimpleDateFormat这个类的源码。果然,在这个类的注释里,有这么一段话。

     * Date formats are not synchronized.
     * It is recommended to create separate format instances for each thread.
     * If multiple threads access a format concurrently, it must be synchronized
     * externally.

      这段话的意思很简单,翻译过来就是:日期格式化的类是非同步的,建议为每一个线程创建独立的格式化实例。如果多个线程并发访问同一个格式化实例,就必须在外部添加同步机制。

      由于LZ错误的将SimpleDateFormat的单个实例放置于高并发的环境下,并且没有任何同步机制,于是就导致了这个奇葩的问题。接下来,LZ便快速的将代码改成了类似于如下的形式。

    public class DateUtil {
    
        private DateUtil(){}
    
        public static Date parse(String date)  throws ParseException {
            return new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").parse(date);
        }    
    
    }

      果然,那个奇怪的异常再也不出现了,事情到此已经水落石出了。最后,LZ奉上一段示例代码,猿友们运行这个程序,就会出现解析失败的异常,但是很明显,我们的日期格式是正确的。

    import java.text.DateFormat;
    import java.text.ParseException;
    import java.text.SimpleDateFormat;
    import java.util.concurrent.ExecutorService;
    import java.util.concurrent.Executors;
    
    public class DateUtil {
    
        private DateUtil(){}
        
        private static final DateFormat DATE_FORMAT = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
    
        public static void main(String[] args) throws InterruptedException {
            ExecutorService executorService = Executors.newFixedThreadPool(500);
            for (int i = 0; i < 500; i++) {
                executorService.execute(new Runnable() {
                    @Override
                    public void run() {
                        for (int i = 0; i < 1000000; i++) {
                            try {
                                DATE_FORMAT.parse("2014-01-01 00:00:00");
                            } catch (ParseException e) {
                                e.printStackTrace();
                            }
                        }
                    }
                });
            }
            Thread.sleep(3000000);
        }
        
    }

      

    小结

      

      高并发所引发的问题往往很难解决,因为它无法稳定的重现。比如本文中的问题,如果不是在高并发的情况下,可能你的程序运行半年甚至更久,都不一定能出现几次解析失败的异常。就算是偶尔出现,你也可能会以为是日期格式错误,从而忽略掉它,殊不知事实并非如此。

      同样的功能,不同的人写出来的代码质量确实是有很大差距的。就算是本文中这么简单的一个日期工具类,一不小心都可能造成意料之外的错误。幸好JDK的代码写的足够规范,大部分类的线程安全性都写的很清楚,这才让LZ找到了问题根源。

      相信当下有不少猿友认为自己做的项目或是写的代码没有什么技术含量,以至于每日浑浑噩噩,激情匮乏。但是本文就告诉你这样一个道理,不是因为项目让你不能发光,而是因为你才让项目不能发光


    版权声明:本文版权归作者(左潇龙)所有,欢迎转载。但未经作者同意必须保留此段声明,且在文章页面明显位置给出原文连接,否则保留追究法律责任的权利。

    14
    精彩
    0
    感动
    0
    搞笑
    0
    开心
    0
    愤怒
    0
    无聊
    0
    灌水
    0
    惊讶
#1楼     时间:2014-09-06 08:13:00      来源:FerventDesert
结构清晰,好文!
#2楼     时间:2014-09-06 09:05:00      来源:埋头前进的码农
类的静态字段是全局共享的,你在实例类中操作静态字段肯定会出现多线程问题。
#3楼     时间:2014-09-06 09:46:00      来源:尼玛范爷
楼主在一个高大上的项目背景下犯了一个小错误...
一句话,SimpleDateFormat是线程不安全的,并发时会出错....
#4楼     时间:2014-09-06 12:30:00      来源:艾斯泽
赞个!
#5楼     时间:2014-09-06 12:51:00      来源:sunfan
写的很好,学了很多
#6楼     时间:2014-09-06 12:56:00      来源:sunfan
楼主最后一句措辞有点太激烈了,建议稍微改一下比较好。
不是因为项目让你不能发光,而是因为你才让项目不能发光 ×
不要以为项目让你不能发光,而是因为你才能让项目发光 √
:)
#7楼     时间:2014-09-06 14:01:00      来源:不见不散
好文!
#8楼     时间:2014-09-06 14:19:00      来源:左潇龙
@ 不见不散
@sunfan
@艾斯泽
@FerventDesert
十分感谢四位猿友的支持。
#9楼     时间:2014-09-06 14:21:00      来源:左潇龙
@ 尼玛范爷
@埋头前进的码农
以前经常这么用,没出过问题,也就从没在意过并发的问题。谁曾想,不是不报,时候未到啊。幸好亡羊补牢,为时未晚。
#10楼     时间:2014-09-06 14:23:00      来源:左潇龙
@ sunfan
哈哈,LZ是故意口气严厉点的。因为LZ手下就有这样的程序猿,也算是给他们的忠告,LZ还是希望大家和我一起创造伟大的项目。
#11楼     时间:2014-09-06 15:17:00      来源:多油小黄子
顶顶顶顶顶顶顶顶顶顶顶顶顶顶顶顶顶顶顶22222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222
#12楼     时间:2014-09-06 16:30:00      来源:Henry_Wang
装逼。
#13楼     时间:2014-09-06 17:04:00      来源:左潇龙
@ 多油小黄子
感谢猿友的支持。
#14楼     时间:2014-09-06 17:07:00      来源:左潇龙
@ Henry_Wang
猿友的怨念很大啊,是不是最近诸事不顺呢。说出来LZ说不定能帮到什么。
#15楼     时间:2014-09-06 17:45:00      来源:沧海一滴
搞不懂,这种实例有必要定义成静态的吗
按jvm规范,静态的是在方法区,垃圾回收效率不高

项目中也有不少同事,喜欢随便定义静态对象。。。。
#16楼     时间:2014-09-06 18:13:00      来源:左潇龙
@ 沧海一滴
现在来看,自然是不能定义成静态的,因为它不是线程安全的。讨论“有必要定义成静态的吗”这个问题得建立在它是线程安全的前提下才有意义。
就本文的场景而言,如果simpledateformat是线程安全的,那么定义成静态的更好。不过事实上,jvm构造对象的开销很小,因此实际上无论是静态的还是每一个线程构造一个实例,其实区别并不大。至于方法区的回收率不高这个问题,其实不需要考虑,除非simpledateformat占用的内存太大。
#17楼     时间:2014-09-06 22:27:00      来源:地狱门神
如果一定要静态,可以考虑ThreadLocalStorage。
#18楼     时间:2014-09-07 00:11:00      来源:Virgil-Zhou
不是因为项目让你不能发光,而是因为你才让项目不能发光。LZ这句话真心给力,一起共勉。
#19楼     时间:2014-09-07 01:36:00      来源:沧海一滴
@ 左潇龙
code的习惯不同吧。代码量大时,这个差别就体现出来了
#20楼     时间:2014-09-07 01:47:00      来源:水牛刀刀
呵呵,我只能说Java的这类名太sb,明明是个parser,却叫 format,你叫"SimpleDateFormatParser" 是个稍微有经验的程序员都可能想到可能包含数据,会不会有线程安全问题。
#21楼     时间:2014-09-07 02:58:00      来源:左潇龙
@ 地狱门神
其实在已经知道线程不安全的情况下,已经不需要追求静态了。因为构造的花销并不大。
#22楼     时间:2014-09-07 02:58:00      来源:左潇龙
#23楼     时间:2014-09-07 03:00:00      来源:左潇龙
@ 沧海一滴
其实如果不是缓存了大量的数据的话,代码量再大也不会对方法区造成什么压力。唯一需要注意的是别有内存泄漏就行。
#24楼     时间:2014-09-07 03:05:00      来源:左潇龙
@ 水牛刀刀
不得不说,jdk里有一些类名取的是有点蛋疼。。。毕竟大神终究还是人,也有犯糊涂的时候。。
#25楼     时间:2014-09-07 11:07:00      来源:无色
问题就在public static
static破坏了封闭,所有的类共享一个实例,在高并发第一个出问题必定是static,去掉static 可能一点事都没有
无论在java,还是在c++,还是objective-c,尽量少用static,特别在有多线程的场合
static表面上省内存,实质上更占内存,因为static内存很多时候不会被及时释放,static可能会导致性能降低,因为多个类都在等static的资源,static可能会导致并发问题。
oop的原则是能用new object就用new object,尽量不要在oop的墙上打洞。
#26楼     时间:2014-09-07 12:21:00      来源:沧海一滴
#27楼     时间:2014-09-07 13:03:00      来源:左潇龙
@ 无色
还是那句话,看 static变量占的内存大小如何了。小的话其实static更好,否则频繁的gc可不是好事。方法区大部分情况下是完全够用的,除非动态代理泛滥。
#28楼     时间:2014-09-07 13:05:00      来源:左潇龙
@ 沧海一滴
多谢啊,猿友。不过问题已经解决了。目前还不想整这么复杂,而且threadlocal 其实已经不推荐使用了。看hibernate现在session的处理就知道了,已经废弃了threadlocal。
#29楼     时间:2014-09-07 14:30:00      来源:无色
@ 左潇龙
引用@无色
还是那句话,看 static变量占的内存大小如何了。小的话其实static更好,否则频繁的gc可不是好事。方法区大部分情况下是完全够用的,除非动态代理泛滥。

static是定时炸弹,不知道什么时候发作。不去使用它就不会发作。有了new根本没必要使用static,static反而会使内存出现问题,而new的问题比较容易解决。
static是c语言时代的产生,是为了方便共享资源,大凡“共享”的东西都容易出问题,因为“共享”的东西会出出现资源竞争的情况,在一定条件下就会发作。
现代cpu算力过剩,但内存不能崩,所以根本不需要使用static。
#30楼     时间:2014-09-07 14:38:00      来源:左潇龙
@ 无色
猿友对static的理解有点极端了,按照你的说法,javac应该废掉static关键字。
#31楼     时间:2014-09-07 17:50:00      来源:小文字
结尾亮了👍
#32楼     时间:2014-09-07 23:38:00      来源:幻天芒
好文,顶顶。
#33楼     时间:2014-09-14 17:39:00      来源:无色
@ 左潇龙
引用@无色
猿友对static的理解有点极端了,按照你的说法,javac应该废掉static关键字。

static是兼容所谓c++设计的,用于学习测试等单线程环境使用,是历史问题,在生产中中尽量不要使用static,使用new object才能充分发挥oop在jvm上的安全和自动管理性能。
#34楼     时间:2015-05-05 20:26:00      来源:fsbomb
支持。同楼主,09年本科毕业,一直做测试工作,最近打算改行走开发路线,重新学习java和c#,希望得到楼主帮助。
#35楼     时间:2015-05-05 22:25:00      来源:林枫泪
LZ,你可以采用
ThreadLocal<SimpleDateFormat> threadLocal

, 这样或许能避免线程不安全问题,同时也不太影响性能。
#36楼     时间:2015-06-10 23:35:00      来源:little_mq
@ 林枫泪
引用LZ,你可以采用

, 这样或许能避免线程不安全问题,同时也不太影响性能。


同意 在并发的条件下 使用ThreadLocal 来包裹下
public static final ThreadLocal<DateFormat> dateFormate = new ThreadLocal<DateFormat>(){

		@Override
		protected DateFormat initialValue() {		
			return new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
		}		
	};
	
	public static Date parse(String date) throws ParseException{
		if(date == null || date.equals("")){
			return null;
		}
		if (dateFormate.get() == null){
			dateFormate.set(new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"));
		}
		return dateFormate.get().parse(date);	
	}
#37楼     时间:2016-11-18 17:50:00      来源:程序人生0407
学习了。堆:所有线程共享一个堆。成员变量是放在堆内的,所以线程不安全会出问题。可以在方法前加上synchronized也可以保障线程安全。
#38楼     时间:2017-01-13 16:15:00      来源:逃离沙漠
这篇文字我是今天(2017-01-13)在一个微信公账号推送里边看到的,一看时间,竟然是这么早的文章了。楼主确实是个实在人,为了说明一个异常问题,洋洋洒洒的写了上千字。从文字中能感受到楼主解决这个工作中的“BUG”之后的喜悦之情,和成就感。但是我看完之后,难免想说,楼主 有点标题党了。标题起的这么大,想来应该有很高的含金量吧。实则不然,并没有分析bug的原因。1.明明是时间转换,却报错类型转换异常。为啥?这个点没提到。2.原方法为何出错,新的解决方案为什么不错,并没做对比解释。标题这么大,也该延伸点东西吧。只靠标题党来吸引人呐。没别的意思,个人知识感觉你的标题叫做 “竞态条件引发的问题”,或者“多线程环境下共享资源运用不当引发的问题“ 之类的。拙见,勿怪。
#39楼     时间:2017-01-14 21:29:00      来源:一书生VOID
嗯,LZ如果用Java 8的话可以试试新的LocalDateTime类,Java 8以前的时间和日期类都不是线程安全的,包括Calendar,DateFormat。其实还有种办法就是使用ThreadLocale,这样可以保证DateFormat是线程安全的。最近也在研究Java的并发编程,感觉有点深度。
#40楼     时间:2017-04-03 12:44:00      来源:ismallboy
楼主再解释下为什么高并发下该函数就会出现exception就好了?
发表评论

站内搜索

最新评论