标签归档:JVM

一次线程同步问题的Bugfix

前两天碰到一个Java多线程未同步引起的Bug。Bug本身并没多少槽点,无非是本该同步的方法没加同步,造成最后执行结果非预期结果,但是在解决Bug的过程中发现还是有些知识点值得记录下来的。

本文分成两部分:1. Bug分析与解决。 2. synchronized注意事项。

1. Bug分析与解决

Watcher

这是出现Bug的业务场景和关键代码。Wathcer Service监听Etcd中产生的事件,然后提交给线程池处理。假设XJob是负责处理相关Etcd事件的类,其业务逻辑如代码所示,问题出在注释3-5处,当Etcd在很短的时间间隔内连续产生同一个操作对象的两个相同事件(该场景是我在设计之初未考虑到)时,会有多个XJob实例(线程)同时执行到注释3所在的区块。由于注释4处的rpc调用是个比较耗时的操作,因此可能会造成某个Entry在第一个线程中尚未写回到DB时,又被第二个线程取出来操作,导致数据不一致。

分析出问题所在后,最简单的方法就是通过synchronized关键字对相关代码进行同步。修复后的代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public void run() {
    String id = getId(context);
    ......
    if (condition) {
        ......
    } else {
        synchronized(id.intern()) {
            Entry e = getFromDb(id, DEAD);
            rpc(e);
            update2Db(e, ALIVE);
            ......
        }
    }
}

这里有两个问题需在后面解答。

1). 在run()方法前加synchronized来修复Bug是否可行?

2). 为什么要通过synchronized(id.intern()){}而不是synchronized(id){}?

2. synchronized注意事项

1). synchronized关键字可以修饰方法(类方法/实例方法),也可以修饰代码块。本质上,进入synchronized修饰的区块都会得到一把锁,一块代码到底会不会同步执行,关键就是看多线程竞争的是否同一把锁。如此来理解所谓的对象锁和类锁就容易多了。先说对象锁,顾名思义,锁的粒度是对象。比如当synchronized修饰实例方法,它等同于用synchronized(this){}来修饰这个方法内的代码,也就是说除非是多个线程调用同一个实例(对象)的方法,否则synchronized不生效。以此类推,相同实例(对象)的不同synchronized方法也可以同步执行,因为他们的锁对象都是当前对象地址(this指针)。再说类锁,锁的粒度是类,一般修饰类方法或类变量,或者称为静态(static)方法或静态(static)变量。当synchronized修饰static方法时,它等同于用synchronized(类名.class){}来修饰这个静态方法内的代码。

举个栗子。某Test类有静态方法m1(),非静态方法m2()和m3(),三个方法都被synchronized关键字修饰,现有一个Test类的实例test。两个线程T1和T2,执行m2()和m3()时会同步,因为它们都要获得对象锁test。但是它们可以同时调用m1()和m2(),或者m1()和m3(),因为执行m1()是获得类锁Test.class,而执行m2()/m3()是获得对象锁test。

至此第1部分的两个问题原因显而易见了:

(1). run()方法前加synchronized来修复Bug是否可行?不行。因为提交给线程池执行的XJob对象不是单例的,XJob有很多个对象,不能用对象锁的方式。

(2). 为什么不是直接用synchronized(id){}?因为id对象是运行时生成的,这个String对象肯定分配在堆里。既然分配在堆里,即使id相同(equals()返回true),他们也属于不同的对象,不能用对象锁的方式。再看看String对象的intern()方法做了什么,intern()方法表示返回当前String对象在常量池中的地址,如果常量池中尚未存在该对象的值,那么就会将值放入常量池后再返回其地址。例如,两个String对象s1和s2,如果他们的值相同s1.equals(s2),那么s1.intern()==s2.intern()。当然它也有副作用,比如说频繁调用之后会引起Full GC。关于intern()实际上还有很多要注意的地方,涉及到GC性能,而且在不同的JDK版本下表现还不同,等有时间可以写一篇文章整理一下。

2). 虽然synchronized不可以修饰构造方法,但还是可以通过synchronized(this){}的方式修饰构造方法中代码块,然而这并没有什么意义,synchronized不会生效。想象一下,什么场景下多个线程需要初始化出相同地址的一个对象?如果构造方法中的初始化代码真的需要同步,可以通过类锁或其他的方式,但绝不会是synchronized(this)。

3). 进入synchronized代码块时获得的锁是由JVM负责释放的,这意味着无论代码是正常结束还是异常(checked, unchecked)退出,都不必关心锁未释放的问题。

--EOF--

『大型网站技术架构:核心原理与案例分析』(二)

『大型网站技术架构:核心原理与案例分析』读书笔记系列:
(一):架构演化、模式、要素
(二):高性能架构
(三):高可用架构
(四):可伸缩架构
(五):可扩展架构
(六):安全性架构


『大型网站技术架构』(二):高性能架构

一、不同视角下的网站性能

  • 用户视角

    关注点: 关注响应时间,包括浏览器和服务器通信时间 + 服务器处理时间 + 浏览器构造请求和解析响应的时间;
    优化手段: 前端架构优化

  • 开发人员视角

    关注点: 关注应用及相关子系统性能,包括响应延迟、系统吞吐量、并发处理能力、系统稳定性等技术指标;
    优化手段: 缓存加速数据读取、集群提高系统吞吐量、异步消息加快请求响应和削峰、代码优化提升性能;

  • 运维人员视角

    关注点:关注基础设施性能、资源利用率。
    优化手段: 建设优化骨干网、高性价比定制服务器、利用虚拟化技术优化资源利用率;

二、性能测试指标

1. 响应时间

定义:执行一个应用需要的时间,包括从发出请求开始到收到最后响应数据所需要的时间。反映系统快慢。

常用系统操作响应时间表

图1 常用系统操作响应时间表

2. 并发数

定义:系统能够同时处理请求的数目。反映系统负载特性。

网站系统用户数 >> 网站在线用户数 >> 网站并发用户数

3. 吞吐量

定义: 单位时间内系统处理的请求数量。反映系统的整体处理能力。
单位:TPS每秒事务数,HPS每秒HTTP请求数,QPS每秒查询数

并发数由小增大,服务器资源消耗逐渐增大,吞吐量先增大后下降,直至资源耗尽,吞吐量为零。

4. 性能计数器

定义:描述服务器或操作系统性能的一些数据指标。比如load、对象与线程数、内存、CPU、磁盘/网络IO。

三、性能测试方法

1. 性能测试

以系统设计规划的性能指标为预期目标,验证系统在资源可接受范围内是否能达到性能预期。

2. 负载测试

不断增大并发请求增加系统压力,直到系统某项或多项性能指标达到安全临界值。此时如果继续施压,系统处理能力不升反降。

3. 压力测试

对系统持续加压超过安全负载,直到系统崩溃,以此获得系统最大压力承受能力。

4. 稳定性测试

被测系统在特定硬件、软件、网络环境下,给系统加载一定业务压力,使系统运行一段较长时间,以此检测系统是否稳定。

perftest

图2 测试性能曲线

a-b是网站日常运行区间,c是系统最大负载点,d是系统崩溃点。

四、性能优化策略

1. Web前端性能优化

1.1. 浏览器访问优化

  • 减少HTTP请求:合并CSS/JS/图片,sprite技术。
  • 使用浏览器缓存静态资源:Cache-Control、Expires
  • 启用压缩:gzip(文本文件压缩率80%以上)
  • CSS放在页面最上面(浏览器下载完CSS才开始渲染)、JS放在页面最下面(浏览器加载JS后立即执行,避免某些操作阻塞页面)。
  • 减少Cookie传输:减少Cookie中数据量、为静态资源使用独立域名,避免发送Cookie。

1.2. CDN加速:CDN本质是缓存,将数据(静态资源)缓存在离用户最近的地方。
1.3. 反向代理:缓存静态资源,加速请求响应速度、负载均衡,改善性能、安全

2. 应用服务器性能优化

2.1. 分布式缓存
网站性能优化第一定律:优先考虑使用缓存优化性能。

2.1.1. 缓存基本原理:Hash表

2.1.2. 合理使用缓存:

  • 不频繁修改的数据:读写比2:1以上。
  • 有热点的访问:遵循二八定律。
  • 应用能够容忍短期数据不一致和脏读,最终一致。
  • 缓存可用性:防止出现缓存雪崩,即系统性能已经严重依赖缓存,没它不行。
  • 缓存预热: 缓存启动时把热点数据预热(warm up)好。
  • 防止缓存穿透:恶意请求不存在的Key,使请求落到数据库,一个对策是将不存在的数据也缓存起来,令其值为null。

2.1.3. 分布式缓存架构:

  • 需同步更新副本的分布式架构(JBoss Cache)
  • 互不通信的分布式架构(Memcached:简单地通信协议、丰富的客户端程序、高性能网络通信(Libevent、事件触发)、高效的内存管理(slab、chunk)、一致性hash)

2.2. 异步操作
使用消息队列将调用异步化,降低响应延时。

  • 避免高并发请求数据直接落到数据库。
  • 削峰,消除并发访问高峰。

2.3. 使用集群
负载均衡,避免单一服务器压力过大而响应缓慢。

3. 代码优化

3.1. 多线程
启动线程数 = [任务执行时间/(任务执行时间-IO等待时间)] * CPU核数

注意线程安全问题:

  • 将对象设计为无状态:对象无成员变量,在OOP看来是一种不良设计。
  • 使用局部对象。
  • 并发访问资源使用锁。

3.2. 资源复用
尽量减少那些开销很大的系统资源的创建和销毁,比如数据库连接、网络通信连接、线程、复杂对象等。

两种模式:

  • 单例(Singleton): 贫血模式,例如Service、Dao等无状态对象,无需重复创建。(对应有状态的充血模式)
  • 对象池(Object Pool):复用对象实例,减少对象创建和资源消耗,例如连接池、线程池等。

3.3. 数据结构

  • 优化算法
  • 优化数据结构

3.4. 垃圾回收

理解垃圾回收机制,程序优化和参数调优,减少Full GC。

JVM垃圾回收:年轻代+老生代。年轻代包括Eden Space、From、To,进行Young GC;老生代进行Full GC。新对象从Eden Space创建,Eden满了以后YGC,将存活对象复制到From区。当Eden再次满后再次YGC,将Eden和From中的存活对象复制到To。当Eden再次满后再次YGC,将Eden和To中的存活对象复制到From。多次YGC未释放的对象进到老生代,老生代空间满时Full GC。

4. 存储性能优化

4.1. 机械硬盘升级为固态硬盘
4.2. B+树:文件系统或数据库系统通过B+数对数据排序后存储,加快数据检索速度。
4.3. RAID:可通过硬件或者软件实现。

  • RAID0: 并发读写N块磁盘,速度快,无冗余,数据可靠性低,磁盘利用率100%。
  • RAID1: 镜像磁盘,访问速度慢,数据可靠性高,磁盘利用率50%。
  • RAID10: 结合RAID0和RAID1,将磁盘分成2份,互为镜像,同RAID1,每一份磁盘中的数据并发读写。访问速度较快,数据可靠性高,磁盘利用率50%。
  • RAID3/5/6: 冗余存储校验数据,使少数盘破坏的情况下数据能恢复。访问速度较快,数据可靠性较高,磁盘利用率较高。

--EOF--

Java ClassLoader

Java的字节码文件(.class)加载到JVM,形成class对象的过程称为Java类加载过程。将类加载过程进行细分的话,可以分为:
1. 装载:找到Java字节码,加载到JVM。
2. 链接:进一步细分为校验、准备、解析三个过程。对字节码进行格式验证(校验过程),为类变量分配内存,设置默认初始值(准备过程,基本类型为0,引用类型为null),将类、接口、字段和方法中的符号引用替换成直接引用(解析过程,可选)。
3. 初始化:类静态属性和静态块代码初始化。

『Java类加载器运行机制』一文中描述了Java类加载器的层次结构和工作原理(委托模式),也提到ClassLoader类的作用。本文会介绍ClassLoader类中几个主要方法的作用以及自定义一个类加载器需要注意什么。

ClassLoader类提供了findLoadedClass(), defineClass(), resolveClass(), loadClass(), findClass()等几个核心方法。

1. findLoadedClass()
此方法的作用是返回被当前ClassLoader加载的类,带final限定词,意味着它不能被子类重写,子类中直接调用即可。

2. defineClass()
此方法的作用是将Java字节码转换成Class对象,带final限定词,意味着它不能被子类重写,子类中直接调用即可。

3. resolveClass()
此方法的作用是对Class对象进行解析。解析的过程就是将类、接口、字段和方法中的符号引用替换成直接引用的过程。它的限定词为final,意味着它不能被子类重写,子类中直接调用即可。

4. loadClass()
此方法作用为加载指定名字的类。由于loadClass()方法不带final限定词,所以它能被子类重写。在ClassLoader的默认实现中,loadClass加载类有三个步骤:
1). 调用findLoadedClass(),检查该类是否已经被加载。如果已加载过,直接返回。
2). 调用父类加载器的loadClass()方法。如果已经被父类加载器加载,直接返回。
3). 调用findClass()方法尝试加载。

这三个步骤规定了一个Java类的加载顺序,这种默认顺序以一种安全的委托方式加载类,同样适用于大部分自定义类加载器的使用场景。除非要改变类加载的顺序,才会在子类中去重写。

另外,loadClass()方法还提供了一个boolean型的resolve参数,如果resolve为true,表示需要对类进行解析,loadClass的默认实现里,会调用resolveClass()方法。以下是ClassLoader的loadClass源代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
protected synchronized Class<?>loadClass(String name, boolean resolve)
                                         throws ClassNotFoundException {     
        Class c = findLoadedClass(name);
        if (c ==null) {
            try {
                if (parent !=null)
                    c = parent.loadClass(name,false);
                else
                    c = findBootstrapClass0(name);
            }
            catch (ClassNotFoundException e) {             
                c = findClass(name);
            }
        }
        if (resolve) {
            resolveClass(c);
        }
        return c;
}

5. findClass()
此方法的作用为返回待加载的类的Class对象,它不带final限定词,可被子类重写。ClassLoader默认实现中,findClass()方法什么都没做,就是抛出一个ClassNotFoundException。这么设计的原因有两个:
1). 根据loadClass()方法加载类的顺序,如果自己及自己的父类加载器都无法找到目标类的话,足以证明classpath中未包含该类。
2). 提供了一个安全的方便子类重写的方法入口。自定义类加载器可重写该方法。

若要创建自定义类加载器,需要继承自ClassLoader,然后重写或者调用以上方法来实现。一般来说重写findClass()方法即可,只需在findClass()方法的实现中,得到Java字节码内容后,调用defineClass()方法返回一个Class对象。

--EOF--

Log4j配置文件热更新

默认情况下Log4j配置修改后要重启才能生效,而产品上线以后不能轻易停服,有时候遇到线上bug需要调整日志级别,或者磁盘空间满了要调整日志输出路径,这时就要用到apache log4j提供的热更新配置文件接口:PropertyConfigurator.configureAndWatch()DOMConfigurator.configureAndWatch()。前者用来读取和热更新log4j.properties文件,后者用来读取和热更新log4j.xml文件。

用法:
1. 在ServletContextListener或者Tomcat LifecycleListener中调用configureAndWatch API:

PropertyConfigurator.configureAndWatch(log4jConfigFilename, 60000);

configureAndWatch API接受两个参数,log4jConfigFilename表示log4j.properties配置文件所在文件,后一个参数表示下次查看配置文件的间隔时间,默认是60秒(FileWatchdog.DEFAULT_DELAY)。程序中调用configureAndWatch API后,Log4j会单独起一个线程定期查看配置文件,如果发现配置文件有过修改,会启用新的配置。

2. 添加jvm shutdown hook,当jvm退出时可以清理现场。

Runtime.getRuntime().addShutdownHook(new Thread() {
    @Override
    public void run() {
        LogManager.shutdown();
    }
});

configureAndWatch API创建的线程不会在应用取消部署(undeploy)的时候自己结束,因此需要添加jvm shutdown hook来清理。

References:
[1]. Changing Log4j logging levels dynamically. http://blogs.justenougharchitecture.com/?p=185.
[2]. Automatically reload log4j configuration in tomcat. http://janvanbesien.blogspot.jp/2010/02/reload-log4j-configuration-in-tomcat.html.

--EOF--

几种JVM垃圾回收类型及特点

1. 引用计数方式
堆中每个对象都有一个引用计数器,创建对象时,该对象的引用计数置1,此后,当有其他变量引用该对象时,引用计数都会加1。当一个变量被设置成新值或者引用超时后,引用计数减1。当引用计数器值为0时,该对象会被回收。这种gc方式可避免STW问题,但是它无法检测出循环引用的对象,因此会造成内存泄露,而且维护计算器也会造成一定开销。

2. 标记-收集-压缩(mark-sweep-compact)方式
垃圾收集器从root开始,追踪对象的引用图,并标记为活的对象,此为mark过程。遍历整个堆空间,将未被标记为活的对象清理掉,此为sweep过程。对sweep过后的堆空间进行压缩,去除内存碎片,此为compact方式。经过一次mark-sweep-compact处理之后,当前所有活的对象都被转移到一块连续的堆空间中。这种方式的gc不会出现内存泄露问题,但是compact的过程会导致程序暂停运行,而且sweep是对整个堆空间进行扫描,存在性能问题。

3. 拷贝回收
这种方式的垃圾回收是在内存中开辟堆空间两倍大小的空间,每次只使用一半空间。每一次gc都会把这一半中活的对象拷贝到另一半中。这种gc方式效率高,不必遍历整个堆空间,但是gc期间会造成程序停止运行,而且会造成一些生命周期较长的对象反复来回拷贝,并且,它需要的堆大小是其他方式的两倍。

4. 分代收集
分代收集的理论基础是:大部分对象的生命周期很短,但是总有一些对象的生命周期很长。这种gc方式会将堆空间按代分成多个区域,不同区域可采用不同的gc算法。年轻代的堆空间存放年轻对象,这个区域gc较频繁,当一个对象经过多次gc仍旧存活时,就被拷贝到老年代堆空间,这个区域gc不会太频繁。分代收集很好的在性能和程序可用性之间取了平衡,在各jvm的实现中较常见。

--EOF--