3 Inside Terracotta
3.1 Core Terracotta Concepts
3.1.1 Root
共享对象图中的顶层对象被称为root,它在Terracotta的配置文件中指定。所有经root引用可达的对象都会被Terracotta分配一个集群内唯一的object id,并在集群内共享直到被分布式垃圾收集器回收。需要注意的是,声明root对象的类也会被Terracotta隐含地标记为instrumented。
当集群内的某个JVM第一次对root引用赋值的时候,Terracotta会在集群内创建root,并且所有接下来的对root引用的赋值操作均会被Terracotta忽略(除非root是Terracotta中的literal values)。无论被何种修饰符修饰,root对象的生命周期都超过了单个JVM的范畴。尽管Terracotta可以保证集群中的root引用只被赋值一次,但是root对象的内容是可以被修改的,这些修改会被同步到Terracotta server。此外也不能避免对其constructor的调用,例如:
package tcinaction.sharedroot;
public class A {
public static final A ROOT = new A();
public A() {
System.out.println("in A.A(), this: " + this);
}
public static void main(String args[]) throws InterruptedException {
System.out.println("root: " + ROOT);
Thread.sleep(Long.MAX_VALUE);
}
}
tc-config.xml的内容如下:
<?xml version="1.0" encoding="UTF-8"?> <con:tc-config xmlns:con="http://www.terracotta.org/config"> <servers> … </servers> <clients> … </clients> <application> <dso> <locks> <autolock auto-synchronized="false"> <method-expression>void tcinaction.sharedroot.A.main(java.lang.String[])</method-expression> <lock-level>write</lock-level> </autolock> </locks> <instrumented-classes> <include> <class-expression>tcinaction.sharedroot.A</class-expression> </include> </instrumented-classes> <roots> <root> <field-name>tcinaction.sharedroot.A.ROOT</field-name> </root> </roots> </dso> </application> </con:tc-config>
以Terracotta DSO Application的方式运行A后,控制台的输出如下:
in A.A(), this: tcinaction.sharedroot.A@1308c5c
root: tcinaction.sharedroot.A@1308c5c
接下来再次以Terracotta DSO Application的方式运行A,控制台的输出如下:
in A.A(), this: tcinaction.sharedroot.A@6828d4
root: tcinaction.sharedroot.A@83914b
当第二次运行A的时候,A的构造函数会被调用,并打印出这个对象的堆地址是@6828d4,由于集群中的ROOT引用已经在第一运行A的时候被赋值过,Terracotta忽略了第二次对ROOT引用的赋值,所以在main方法中打印出的ROOT地址是@83914b。
为什么第二次运行A时打印出来的ROOT地址(@83914b)跟第一次运行A时打印出的ROOT地址(@1308c5c)不同?Terracotta通过proxy保证了集群范围内的对象唯一性,这是逻辑上的唯一性,也就是说@1308c5c和@83914b是逻辑上的同一个ROOT实例。
3.1.2 Transactions
Terracotta 事务是一系列对共享对象修改的原子集合,它的边界是集群锁(clustered lock)的获取和释放。当某个线程获取集群锁时事务开启,对共享对象的修改都被加入到该事务中。在释放集群锁之后,事务被提交。
所有对共享对象的修改都必须在Terracotta 事务的上下文中。这意味着必须首先获得集群锁,然后才能对共享对象进行修改。如果某个线程试图在Terracotta 事务之外(更严格地说,还需要在经过字节码加强后代码中)修改共享对象,那么会导致运行时异常。在某个线程A获取集群锁之前,Terracotta会保证集群中由这把锁界定的事务中对共享对象的修改都会对线程A可见(这类似于Java语言规范的happens before语义和内存可见性的保证)。
3.1.3 Locks
Locks在Terracotta中有两个职责:协调多线程的并发访问和定义Terracotta 事务边界。Terracotta要求: Java代码中所有对共享对象的写操作都必须被同步(可以使用synchronized关键字或者java.util.concurrent包中锁相关的工具类)。But why?Terracotta的理念是并发程序的设计是充满挑战的,需要尽可能地减少犯错的可能。有些并发问题在开发过程中无法发现,在测试过程中无法发现,却可能在你最重要的客户面前爆发。解决同样一个问题,时机的不同(比如是在开发阶段,或者是在production阶段)可能就决定了你老板的大拇指是向上还是向下。
根据配置,Terracotta会对应用程序的字节码进行加强,以便增加Cluster locking相关的行为。配置文件中对集群锁的配置是在方法级别上。需要注意的是,如果声明这些方法的类没有被标记为instrumented,那么这些方法不会有任何Cluster locking相关的行为。也就是说如果在没有标记为instrumented的类中修改共享对象(这不会导致运行时异常,原因是Terracotta无法对uninstrumented的类进行检查),那么这些修改不会被同步到Terracotta server,也就不会对集群中的其它成员可见,从而导致共享对象在集群成员中处于不一致的状态。
3.1.3.1 Autolock
Terracotta里最常见的集群锁由autolock配置。Terracotta会检查所有与autolock匹配的方法中的同步块,通过拦截MONITORENTER和MONITOREXIT字节码指令,以增加Cluster locking相关的行为。以下是个简单的例子:
<autolock auto-synchronized="false"> <method-expression>* tcinaction.locks.A.*(..)</method-expression> <lock-level>write</lock-level> </autolock>
"method-expression" 指定了匹配的方法(通过AspectWerkz Pattern Selection Language)。以上例子中,表达式"* tcinaction.locks.A.*(..)" 匹配tcinaction.locks.A类中所有的方法:表达式开头的 "*" 匹配所有可能的返回值类型;表达式结尾的 "*" 匹配所有的方法名;"(..)" 匹配所有可能的方法参数(包括没有参数)。autolock 还有一个auto-synchronize属性,如果为true,那么等效于该方法被synchronized关键字修饰。该属性通常用于已有的程序中修改共享对象的代码没有被同步,却无法直接重构代码的情况下。
3.1.3.2 Named Lock
除了autolock之外,Terracotta还支持named lock。跟autolock类似,"method-expression" 指定了匹配的方法;"lock-name"指定了命名锁的名字。当某个线程试图执行这些方法时,会从Terracotta server获得一个命名锁,这是一种非常粗粒度的集群锁,可能严重地影响应用性能,因此应该谨慎地使用。以下是个简单的例子:
<named-lock> <lock-name>lockOne</lock-name> <method-expression>* tcinaction.locks.A.*(..)</method-expression> <lock-level>write</lock-level> </named-lock>
3.1.3.3 Lock Level
所有与lock相关的配置还有一个属性lock level。Terracotta提供了以下四种可选级别:
- Write。跟Java中的互斥锁类似,write locks保证任何时刻,集群中最多只有一个线程能够获得该锁。
- Read。多个线程可以同时获得该锁,但是在持有该锁的时候不能修改共享对象(会导致运行时异常)。持有该锁的时候不能升级为Write locks,这也会导致运行时异常。当某个线程持有该锁的时候,在相同共享对象上试图获得Write locks的线程会被阻塞;当某个线程持有Write locks的时候,在相同共享对象上试图获得Read locks的线程也会被阻塞。在读取共享对象的时候,虽然Terracotta并不强制要求必须首先获得Read locks,但是在不持有锁的情况下试图读取共享对象可能会导致脏读,强烈建议不要这样做。
- Synchronous-Write。该锁在Write locks的基础上进一步保证持有该锁的线程直到所有的修改已经被同步到Terracotta server,并得到Terracotta server的应答之后才会释放该锁。
- Concurrent。该锁只是定义了事务边界。通常该锁只是被Terracotta libraries使用,并不建议在应用程序中使用。
3.1.4 Portability
3.1.1节曾经介绍过,在不同的JVM中打印同一个root的堆地址是不同的,因此对于那些依赖于Object.hashCode的实现(即没有改写该方法)的共享对象来说,在不同的JVM中得到的hash code是不同的。假如集群中有个HashMap<Object, Object>类型的共享对象,在两个JVM中以同一个Object对象作为key向该HashMap中存放数据时,会不会因为hashCode不同而导致保存到两个不同的entry中呢?在回答这个问题之前,首先介绍一下Terracotta中portability相关的概念。
可以被Terracotta共享的对象被称为"portable",与之对应的类必须被标记为instrumented。绝大多数被标记为instrumented的类的实例都是portable,但是有一小部分对象由于包含了特定平台或JVM相关的信息,因此不是portable(例如java.io.FileDescriptor、Thread和Runtime等)。同样,继承自non-portable类的实例也不是portable。
对于绝大多数的对共享对象,Terracotta可以跟踪对其的修改,并将修改后的新值同步到Terracotta server,这些共享对象被称为Physically Managed Object。然而有一些对象不是通过这种方式共享的:当共享对象被修改时,Terracotta会记录修改共享对象时所调用的方法签名和方法参数,然后在集群中的其它JVM上再次调用该方法(这其实是分布式方法调用,会在稍后介绍),这些调用也被称为"logic action"。通过这种方式被共享的对象被称为是Logically Managed Objects。通常有两种原因导致对象被logically managed:
- 性能。
- 共享对象包含了特定JVM相关的信息(例如java.util.Hashtable、java.util.HashMap和java.util.HashSet都是logically managed)。
至此,本节开头提出的问题的答案也应该明了了。
尽管logically managed objects是portable,但是由于Terracotta实现细节的原因,logically managed classes有以下限制:如果某个类继承自logically managed classes,并且声明了额外的成员变量,假如符合以下条件,那么这个类就不是portable:
- 直接写入从父类中继承的成员变量。
- 改写了从父类继承的方法。
当某个对象被集群共享的时候(例如被赋值到某个root引用),Terracotta首先会遍历通过该对象引用可达的整个对象图,同时检查对象图中的每个对象(可以配置成忽略transient对象)的portability,如果非portable则抛出运行时异常。此外Terracotta也会对logic action的方法参数进行portability检查。
3.1.5 Boot Jars
Terracotta会在应用程序的字节码中织入集群相关的代码,通常的时机是在类载入的时候。可以配置成对所有载入的类都进行字节码加强,这是最简单和最安全的方法。随着你对Terracotta理解的深入,你会越来越清楚究竟那些类需要进行字节码加强,从而在配置文件中指定(通过AspectWerkz Class Selection Expression)需要进行字节码加强的类,这样会加快类载入的速度,以及提高运行时的性能。
对于绝大多数类,Terracotta可以在载入时进行字节码加强,然而有些类(如rt.jar中的类)是通过boot classloader(是非Java实现的classloader)载入的,Terracotta无法对这些类在载入时进行字节码加强。因此Terracotta提供了Boot JAR Tool,它可以选择性地对这些类进行预处理,生成一个boot jar并保存到boot classpath的优先位置中。需要注意的是,由boot classloader载入的类无法被共享,除非它被包含在boot jar中;同样,继承自由boot classloader载入的类也无法被共享,除非它们的父类被包含在boot jar中。
3.1.6 Transience
与Java序列化机制中的transient关键字类似,Terracotta提供了transience机制以避免共享对象中的部分成员变量被集群共享。此外Terracotta还提供了初始化transient成员变量的机制。以下是其配置的例子:
<transient-fields> <field-name>tcinaction.sharedroot.A.logger</field-name> </transient-fields>
以上的例子中,A的logger成员变量被标记为transient。
此外也可以使用Java的transient关键字修饰logger。在默认情况下,Terracotta在共享某个对象的时候不会忽略该对象中被transient修饰的成员变量。如果希望Terracotta对其进行忽略,那么需要在include节点内增加honor-transient,例如:
<instrumented-classes> <include> <honor-transient>true</honor-transient> <class-expression>tcinaction.sharedroot.A</class-expression> </include> </instrumented-classes>
当包含transient成员变量的共享对象在集群中的某个JVM中被构造的时候,可以在配置文件中指定on-load,以便对transient成员变量进行初始化(如果无on-load配置,那么transient成员变量的值会是null或0)。on-load的配置有两种方式:
- 指定需要执行的方法名。
- 指定需要执行的BeanShell脚本。
3.1.7 Distributed Method Invocation
在Terracotta配置文件中,共享对象的任何方法都可以表标记为distributed,这意味着在集群的某个JVM中对该方法的调用,都会在集群的其它JVM中以相同的参数调用。DMI通常被用来作为分布式listener的实现。笔者建议,不要在分布式方法内修改共享对象的非transient成员变量。以下是其配置的例子:
<distributed-methods> <method-expression>void tcinaction.locks.A.onIdChanged()</method-expression> </distributed-methods>
3.1.8 Distributed Garbage Collection
Terracotta virtual heap类似于操作系统的虚拟内存,它允许集群中的JVM访问超过其本地堆最大容量的共享对象。也就是说集群的各个JVM的本地堆中并不一定包含所有的共享对象。当某个JVM需要访问某个共享对象时,如果该对象并不存在于该JVM的本地堆中,那么会从Terracotta server进行lazy load。
与之相反,Virtual Memory Manager(VMM)会将最少使用的共享对象的引用设置为null,从而在本地堆中清除。假如共享对象A包含了对共享对象B和C的引用,如果C并不经常被访问,那么VMM可能将C的引用设置为null,从而A被VMM部分清除(C被清除,B仍然被保留)。需要注意的是,对于大部分的Logically managed objects,VMM不能进行部分清除。目前VMM可以进行部分清除的Logically managed objects有ConcurrentHashMap、HashMap、 Hashtable、LinkedHashMap和Java arrays。
Distributed Garbage Collector(DGC)由Terracotta server执行,它的职责是从共享对象中标记不再被使用的共享对象,以便从Terracotta server的内存和持久存储中安全地删除。当共享对象不被任何root经引用可达,并且不存在于任何客户端的本地堆中时(出于各种原因,Terracotta server知道加入到集群的各个JVM的本地堆中包含哪些共享对象),它就可以被DGC回收(root对象不会被DGC回收)。
3.2 Terracotta Cluster
多个Terracotta server可以组成一个Terracotta server array。其中的每一个Terracotta server都履行以下两个职责:
- 集群内的并发管理和锁管理。Terracotta server跟踪各个JVM内哪些线程持有集群范围内的锁;哪些线程调用了共享对象的wait方法,以便在该共享对象的notify和notifyAll方法被调用后,可以正确地唤醒等待的线程。
- 共享对象的管理和持久化保存。Terracotta server跟踪各个Terracotta client中的共享对象,以及对其的修改,并根据配置以决定是否将修改进行持久化保存,同时通知其它需要访问这些共享对象的Terracotta client。
如果某个Terracotta server无法正常工作,那么相应的后果由以下因素决定:
- Terracotta server是否工作在持久化模式下。
- Standby Terracotta server是否被配置,以及是否在等待接管active server。
- 该Terracotta server是否是Terracotta server array中的一员。
如果该Terracotta server是Terracotta server array中的一员,那么集群可以继续正常工作。如果不是Terracotta server array中的一员,并且是唯一的active server,那么集群的行为如下:
Restarted Server's Mode | Standby Server | Existing Data | Existing Clients Allowed Reconnect? | New Clients Allowed Reconnect? |
Non-persistent | Not allowed | Lost | No | No |
Persistent | None set up | Saved | Yes | No |
Persistent | Yes - becomes active | Saved | Yes | No |
如果集群中某个Terracotta client无法正常工作,那么Terracotta server会把它从集群中移除,回收该client持有的锁,并且拒绝该client的重连请求(因为该client可能出于某种中间状态)。然而可以为这个client配置一个重连窗口,以便处理网络连接短暂失效的情况。当重连窗口打开的时候,之前连接过的client被允许重新连接。当某个client没有和Terracotta server建立连接之前,该client内所有试图获得锁的操作均被阻塞。