多线程基础
前言
本文主要讲解java多线程的基础,以及一些常用方法。关于线程同步、ExecutorService框架我会放到后续的文章进行讲解。
进程与线程的区别
进程
进程简单的来说就是在内存中运行的应用程序,一个进程可以启动多个线程。
比如在windows中一个运行EXE文件就是一个进程。
线程
同一个线程中的进程共用相同的地址空间,同时共享进程所拥有的内存和其他资源。
线程Demo-继承Thread类
首先我们我们继承java.lang.Thread
类来创建线程。
package top.crosssoverjie.study.Thread;
public class TestThread {
public static void main(String[] args) {
System.out.println("主线程ID是:" + Thread.currentThread().getId());
MyThread my = new MyThread("线程1");
my.start() ;
MyThread my2 = new MyThread("线程2") ;
/**
* 这里直接调用my2的run()方法。
*/
my2.run() ;
}
}
class MyThread extends Thread {
private String name;
public MyThread(String name) {
this.name = name;
}
@Override
public void run() {
System.out.println("名字:" + name + "的线程ID是="
+ Thread.currentThread().getId());
}
}
输出结果:
主线程ID是:1
名字:线程2的线程ID是=1
名字:线程1的线程ID是=9
由输出结果我们可以得出以下结论:
- my和my2的线程ID不相同,my2和主线程ID相同。说明直接调用
run()
方法不会创建新的线程,而是在主线程中直接调用的run()
方法,和普通的方法调用没有区别。- 虽然my的
start()
方法是在my2的run()
方法之前调用,但是却是后输出内容,说明新建的线程并不会影响主线程的执行。
线程Demo-实现Runnable接口
除了继承java.lang.Thread
类之外,我们还可以实现java.lang.Runnable
接口来创建线程。
package top.crosssoverjie.study.Thread;
public class TestRunnable {
public static void main(String[] args) {
System.out.println("主线程的线程ID是"+Thread.currentThread().getId());
MyThread2 my = new MyThread2("线程1") ;
Thread t = new Thread(my) ;
t.start() ;
MyThread2 my2 = new MyThread2("线程2") ;
Thread t2 = new Thread(my2) ;
/**
* 方法调用,并不会创建线程,依然是主线程
*/
t2.run() ;
}
}
class MyThread2 implements Runnable{
private String name ;
public MyThread2(String name){
this.name = name ;
}
@Override
public void run() {
System.out.println("线程"+name+"的线程ID是"+Thread.currentThread().getId());
}
}
输出结果:
主线程的线程ID是1
线程线程2的线程ID是1
线程线程1的线程ID是9
notes:
- 实现Runnable的方式需要将实现Runnable接口的类作为参数传递给Thread,然后通过Thread类调用
Start()
方法来创建线程。- 这两种方式都可以来创建线程,至于选择哪一种要看自己的需求。直接继承Thread类的话代码要简洁一些,但是由于java只支持单继承,所以如果要继承其他类的同时需要实现线程那就只能实现Runnable接口了,这里更推荐实现Runnable接口。
实际上如果我们查看Thread类的源码我们会发现Thread是实现了Runnable接口的:
Thread源码
线程中常用的方法
序号 | 方法 | 介绍 |
---|---|---|
1 | public void start() | 使该线程执行,java虚拟机会调用该线程的run() 方法。 |
2 | public final void setName(String name) | 修改线程名称。 |
3 | public final void setPriority(int privority) | 修改线程的优先级。 |
4 | public final void setDaemon(false on) | 将该线程标记为守护线程或用户线程,当正在运行线程都是守护线程时,java虚拟机退出,该方法必须在启动线程前调用。 |
5 | public final void join(long mills) | 等待该线程的终止时间最长为mills毫秒。 |
6 | public void interrupt() | 中断线程。 |
7 | public static boolean isAlive() | 测试线程是否处于活动状态。如果该线程已经启动尚未终止,则为活动状态。 |
8 | public static void yield() | 暂停当前线程执行的对象,并执行其他线程。 |
9 | public static void sleep(long mills) | 在指定毫秒数内,让当前执行的线程休眠(暂停)。 |
10 | public static Thread currentThread() | 返回当前线程的引用。 |
方法详解- public static void sleep(long mills)
package top.crosssoverjie.study.Thread;
public class TestSleep {
private int i = 10 ;
private Object ob = new Object() ;
public static void main(String[] args) {
TestSleep t = new TestSleep() ;
MyThread3 thread1 = t.new MyThread3() ;
MyThread3 thread2 = t.new MyThread3() ;
thread1.start() ;
thread2.start() ;
}
class MyThread3 extends Thread{
@Override
public void run() {
synchronized (ob) {
i++ ;
System.out.println("i的值:"+i);
System.out.println("线程:"+Thread.currentThread().getName()+"进入休眠状态");
try {
Thread.currentThread().sleep(1000) ;
} catch (Exception e) {
e.printStackTrace();
}
System.out.println("线程:"+Thread.currentThread().getName()+"休眠结束");
i++;
System.out.println("i的值>:"+i);
}
}
}
}
输出结果:
i的值:11
线程:Thread-0进入休眠状态
线程:Thread-0休眠结束
i的值>:12
i的值:13
线程:Thread-1进入休眠状态
线程:Thread-1休眠结束
i的值>:14
由输出结果我们可以得出:
- 当Thread0进入休眠状态时,Thread1并没有继续执行,而是等待Thread0休眠结束释放了对象锁,Thread1才继续执行。
当调用sleep()
方法时,必须捕获异常或者向上层抛出异常。当线程休眠时间满时,并不一定会马上执行,因为此时有可能CPU正在执行其他的任务,所以调用了sleep()
方法相当于线程进入了阻塞状态。
方法详解- public static void yield()
package top.crosssoverjie.study.Thread;
public class Testyield {
public static void main(String[] args) {
MyThread4 my = new MyThread4() ;
my.start() ;
}
}
class MyThread4 extends Thread{
@Override
public void run() {
long open = System.currentTimeMillis();
int count= 0 ;
for(int i=0 ;i<1000000;i++){
count= count+(i+1);
// Thread.yield() ;
}
long end = System.currentTimeMillis();
System.out.println("用时:"+(end-open)+"毫秒");
}
}
输出结果:用时:1毫秒
如果将 Thread.yield()注释取消掉,输出结果:用时:116毫秒
- 调用
yield()
方法是为了让当前线程交出CPU权限,让CPU去执行其他线程。它和sleep()
方法类似同样是不会释放锁。但是yield()
不能控制具体的交出CUP的时间。并且它只能让相同优先级的线程获得CPU执行时间的机会。- 调用
yield()
方法不会让线程进入阻塞状态,而是进入就绪状态,它只需要等待重新获取CPU的时间,这一点和sleep()
方法是不一样的。
方法详解- public final void join()
在很多情况下我们需要在子线程中执行大量的耗时任务,但是我们主线程又必须得等待子线程执行完毕之后才能结束,这就需要用到 join()
方法了。join()
方法的作用是等待线程对象销毁,如果子线程执行了这个方法,那么主线程就要等待子线程执行完毕之后才会销毁,请看下面这个例子:
package top.crosssoverjie.study.Thread;
public class Testjoin {
public static void main(String[] args) throws InterruptedException {
new MyThread5("t1").start() ;
for (int i = 0; i < 10; i++) {
if(i == 5){
MyThread5 my =new MyThread5("t2") ;
my.start() ;
my.join() ;
}
System.out.println("main当前线程:"+Thread.currentThread().getName()+" "+i);
}
}
}
class MyThread5 extends Thread{
public MyThread5(String name){
super(name) ;
}
@Override
public void run() {
for (int i = 0; i < 5; i++) {
System.out.println("当前线程:"+Thread.currentThread().getName()+" "+i);
}
}
}
输出结果:
main当前线程:main 0
当前线程:t1 0
当前线程:t1 1
main当前线程:main 1
当前线程:t1 2
main当前线程:main 2
当前线程:t1 3
main当前线程:main 3
当前线程:t1 4
main当前线程:main 4
当前线程:t2 0
当前线程:t2 1
当前线程:t2 2
当前线程:t2 3
当前线程:t2 4
main当前线程:main 5
main当前线程:main 6
main当前线程:main 7
main当前线程:main 8
main当前线程:main 9
如果我们把join()
方法注释掉之后:
main当前线程:main 0
当前线程:t1 0
main当前线程:main 1
当前线程:t1 1
main当前线程:main 2
当前线程:t1 2
main当前线程:main 3
当前线程:t1 3
main当前线程:main 4
当前线程:t1 4
main当前线程:main 5
main当前线程:main 6
main当前线程:main 7
main当前线程:main 8
main当前线程:main 9
当前线程:t2 0
当前线程:t2 1
当前线程:t2 2
当前线程:t2 3
当前线程:t2 4
由上我们可以得出以下结论:
- 在使用了
join()
方法之后主线程会等待子线程结束之后才会结束。
方法详解- setDaemon(boolean on)
,getDaemon()
用来设置是否为守护线程和判断是否为守护线程。
notes:
- 守护线程依赖于创建他的线程,而用户线程则不需要。如果在
main()
方法中创建了一个守护线程,那么当main方法执行完毕之后守护线程也会关闭。而用户线程则不会,在JVM中垃圾收集器的线程就是守护线程。
优雅的终止线程
有三种方法可以终止线程,如下:
- 使用退出标识,使线程正常的退出,也就是当
run()
方法完成后线程终止。 - 使用
stop()
方法强行关闭,这个方法现在已经被废弃,不推荐使用 - 使用
interrupt()
方法终止线程。
具体的实现代码我将在下一篇博文中将到。。
线程的优先级
在操作系统中线程是分优先级的,优先级高的线程CPU将会提供更多的资源,在java中我们可以通过setPriority(int newPriority)
方法来更改线程的优先级。
在java中分为1~10这个十个优先级,设置不在这个范围内的优先级将会抛出IllegalArgumentException
异常。
java中有三个预设好的优先级:
public final static int MIN_PRIORITY = 1;
public final static int NORM_PRIORITY = 5;
public final static int MAX_PRIORITY = 10;
参考
java多线程思维图
---------------------------------------------------------------------------
乐观锁与悲观锁的实际应用
前言
随着互联网的兴起,现在三高(高可用、高性能、高并发
)项目是越来越流行。
本次来谈谈高并发。首先假设一个业务场景:数据库中有一条数据,需要获取到当前的值,在当前值的基础上+10
,然后再更新回去。
如果此时有两个线程同时并发处理,第一个线程拿到数据是10,+10=20更新回去。第二个线程原本是要在第一个线程的基础上再+20=40
,结果由于并发访问取到更新前的数据为10,+20=30
。
这就是典型的存在中间状态,导致数据不正确。来看以下的例子:
并发所带来的问题
和上文提到的类似,这里有一张price
表,表结构如下:
CREATE TABLE `price` (
`id` int(11) NOT NULL AUTO_INCREMENT COMMENT '主键',
`total` decimal(12,2) DEFAULT '0.00' COMMENT '总值',
`front` decimal(12,2) DEFAULT '0.00' COMMENT '消费前',
`end` decimal(12,2) DEFAULT '0.00' COMMENT '消费后',
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=1268 DEFAULT CHARSET=utf8
我这里写了一个单测:就一个主线程,循环100次,每次把front
的值减去10,再写入一次流水记录,正常情况是写入的每条记录都会每次减去10。
/**
* 单线程消费
*/
@Test
public void singleCounsumerTest1(){
for (int i=0 ;i<100 ;i++){
Price price = priceMapper.selectByPrimaryKey(1);
int ron = 10 ;
price.setFront(price.getFront().subtract(new BigDecimal(ron)));
price.setEnd(price.getEnd().add(new BigDecimal(ron)));
price.setTotal(price.getFront().add(price.getEnd()));
priceMapper.updateByPrimaryKey(price) ;
price.setId(null);
priceMapper.insertSelective(price) ;
}
}
执行结果如下:
可以看到确实是每次都递减10。
但是如果是多线程的情况下会是如何呢:
我这里新建了一个
PriceController
/**
* 线程池 无锁
* @param redisContentReq
* @return
*/
@RequestMapping(value = "/threadPrice",method = RequestMethod.POST)
@ResponseBody
public BaseResponse<NULLBody> threadPrice(@RequestBody RedisContentReq redisContentReq){
BaseResponse<NULLBody> response = new BaseResponse<NULLBody>() ;
try {
for (int i=0 ;i<10 ;i++){
Thread t = new Thread(new Runnable() {
@Override
public void run() {
Price price = priceMapper.selectByPrimaryKey(1);
int ron = 10 ;
price.setFront(price.getFront().subtract(new BigDecimal(ron)));
price.setEnd(price.getEnd().add(new BigDecimal(ron)));
priceMapper.updateByPrimaryKey(price) ;
price.setId(null);
priceMapper.insertSelective(price) ;
}
});
config.submit(t);
}
response.setReqNo(redisContentReq.getReqNo());
response.setCode(StatusEnum.SUCCESS.getCode());
response.setMessage(StatusEnum.SUCCESS.getMessage());
}catch (Exception e){
logger.error("system error",e);
response.setReqNo(response.getReqNo());
response.setCode(StatusEnum.FAIL.getCode());
response.setMessage(StatusEnum.FAIL.getMessage());
}
return response ;
}
其中为了节省资源使用了一个线程池:
@Component
public class ThreadPoolConfig {
private static final int MAX_SIZE = 10 ;
private static final int CORE_SIZE = 5;
private static final int SECOND = 1000;
private ThreadPoolExecutor executor ;
public ThreadPoolConfig(){
executor = new ThreadPoolExecutor(CORE_SIZE,MAX_SIZE,SECOND, TimeUnit.MICROSECONDS,new LinkedBlockingQueue<Runnable>()) ;
}
public void submit(Thread thread){
executor.submit(thread) ;
}
}
关于线程池的使用今后会仔细探讨。这里就简单理解为有10个线程并发去处理上面单线程的逻辑,来看看结果怎么样:
会看到明显的数据错误,导致错误的原因自然就是有线程读取到了中间状态进行了错误的更新。
进而有了以下两种解决方案:悲观锁和乐观锁。
悲观锁
简单理解下悲观锁:当一个事务锁定了一些数据之后,只有当当前锁提交了事务,释放了锁,其他事务才能获得锁并执行操作。
使用方式如下:
首先要关闭MySQL的自动提交:set autocommit = 0;
bigen --开启事务
select id, total, front, end from price where id=1 for update
insert into price values(?,?,?,?,?)
commit --提交事务
这里使用select for update
的方式利用数据库开启了悲观锁,锁定了id=1的这条数据(注意:这里除非是使用了索引会启用行级锁,不然是会使用表锁,将整张表都锁住。
)。之后使用commit
提交事务并释放锁,这样下一个线程过来拿到的就是正确的数据。
悲观锁一般是用于并发不是很高,并且不允许脏读等情况。但是对数据库资源消耗较大。
乐观锁
那么有没有性能好,支持的并发也更多的方式呢?
那就是乐观锁。
乐观锁是首先假设数据冲突很少,只有在数据提交修改的时候才进行校验,如果冲突了则不会进行更新。
通常的实现方式增加一个version
字段,为每一条数据加上版本。每次更新的时候version+1
,并且更新时候带上版本号。实现方式如下:
新建了一张price_version
表:
CREATE TABLE `price_version` (
`id` int(11) NOT NULL AUTO_INCREMENT COMMENT '主键',
`total` decimal(12,2) DEFAULT '0.00' COMMENT '总值',
`front` decimal(12,2) DEFAULT '0.00' COMMENT '消费前',
`end` decimal(12,2) DEFAULT '0.00' COMMENT '消费后',
`version` int(11) DEFAULT '0' COMMENT '并发版本控制',
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=1268 DEFAULT CHARSET=utf8
更新数据的SQL:
<update id="updateByVersion" parameterType="com.crossoverJie.pojo.PriceVersion">
UPDATE price_version
SET front = #{front,jdbcType=DECIMAL},
version= version + 1
WHERE id = #{id,jdbcType=INTEGER}
AND version = #{version,jdbcType=INTEGER}
</update>
调用方式:
/**
* 线程池,乐观锁
* @param redisContentReq
* @return
*/
@RequestMapping(value = "/threadPriceVersion",method = RequestMethod.POST)
@ResponseBody
public BaseResponse<NULLBody> threadPriceVersion(@RequestBody RedisContentReq redisContentReq){
BaseResponse<NULLBody> response = new BaseResponse<NULLBody>() ;
try {
for (int i=0 ;i<3 ;i++){
Thread t = new Thread(new Runnable() {
@Override
public void run() {
PriceVersion priceVersion = priceVersionMapper.selectByPrimaryKey(1);
int ron = new Random().nextInt(20);
logger.info("本次消费="+ron);
priceVersion.setFront(new BigDecimal(ron));
int count = priceVersionMapper.updateByVersion(priceVersion);
if (count == 0){
logger.error("更新失败");
}else {
logger.info("更新成功");
}
}
});
config.submit(t);
}
response.setReqNo(redisContentReq.getReqNo());
response.setCode(StatusEnum.SUCCESS.getCode());
response.setMessage(StatusEnum.SUCCESS.getMessage());
}catch (Exception e){
logger.error("system error",e);
response.setReqNo(response.getReqNo());
response.setCode(StatusEnum.FAIL.getCode());
response.setMessage(StatusEnum.FAIL.getMessage());
}
return response ;
}
处理逻辑:开了三个线程生成了20以内的随机数更新到front
字段。
当调用该接口时日志如下:
可以看到线程1、4、5分别生成了15,2,11三个随机数。最后线程4、5都更新失败了,只有线程1更新成功了。
查看数据库:
发现也确实是更新的15。
乐观锁在实际应用相对较多,它可以提供更好的并发访问,并且数据库开销较少,但是有可能存在脏读的情况。
总结
以上两种各有优劣,大家可以根据具体的业务场景来判断具体使用哪种方式来保证数据的一致性。
---------------------------------------------------------------------------
Synchronize 关键字原理
众所周知 Synchronize
关键字是解决并发问题常用解决方案,有以下三种使用方式:
- 同步普通方法,锁的是当前对象。
- 同步静态方法,锁的是当前
Class
对象。 - 同步块,锁的是
{}
中的对象。
实现原理:JVM
是通过进入、退出对象监视器( Monitor
)来实现对方法、同步块的同步的。
具体实现是在编译之后在同步方法调用前加入一个 monitor.enter
指令,在退出方法和异常处插入 monitor.exit
的指令。
其本质就是对一个对象监视器( Monitor
)进行获取,而这个获取过程具有排他性从而达到了同一时刻只能一个线程访问的目的。
而对于没有获取到锁的线程将会阻塞到方法入口处,直到获取锁的线程 monitor.exit
之后才能尝试继续获取锁。
流程图如下:
通过一段代码来演示:
public static void main(String[] args) {
synchronized (Synchronize.class){
System.out.println("Synchronize");
}
}
使用 javap -c Synchronize
可以查看编译之后的具体信息。
public class com.crossoverjie.synchronize.Synchronize {
public com.crossoverjie.synchronize.Synchronize();
Code:
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return
public static void main(java.lang.String[]);
Code:
0: ldc #2 // class com/crossoverjie/synchronize/Synchronize
2: dup
3: astore_1
**4: monitorenter**
5: getstatic #3 // Field java/lang/System.out:Ljava/io/PrintStream;
8: ldc #4 // String Synchronize
10: invokevirtual #5 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
13: aload_1
**14: monitorexit**
15: goto 23
18: astore_2
19: aload_1
20: monitorexit
21: aload_2
22: athrow
23: return
Exception table:
from to target type
5 15 18 any
18 21 18 any
}
可以看到在同步块的入口和出口分别有 monitorenter,monitorexit
指令。
锁优化
synchronize
很多都称之为重量锁,JDK1.6
中对 synchronize
进行了各种优化,为了能减少获取和释放锁带来的消耗引入了偏向锁
和轻量锁
。
轻量锁
当代码进入同步块时,如果同步对象为无锁状态时,当前线程会在栈帧中创建一个锁记录(Lock Record
)区域,同时将锁对象的对象头中 Mark Word
拷贝到锁记录中,再尝试使用 CAS
将 Mark Word
更新为指向锁记录的指针。
如果更新成功,当前线程就获得了锁。
如果更新失败 JVM
会先检查锁对象的 Mark Word
是否指向当前线程的锁记录。
如果是则说明当前线程拥有锁对象的锁,可以直接进入同步块。
不是则说明有其他线程抢占了锁,如果存在多个线程同时竞争一把锁,轻量锁就会膨胀为重量锁。
解锁
轻量锁的解锁过程也是利用 CAS
来实现的,会尝试锁记录替换回锁对象的 Mark Word
。如果替换成功则说明整个同步操作完成,失败则说明有其他线程尝试获取锁,这时就会唤醒被挂起的线程(此时已经膨胀为重量锁
)
轻量锁能提升性能的原因是�:认为大多数锁在整个同步周期都不存在竞争,所以使用 CAS
比使用互斥开销更少。但如果锁竞争激烈,轻量锁就不但有互斥的开销,还有 CAS
的开销,甚至比重量锁更慢。
偏向锁
为了进一步的降低获取锁的代价,JDK1.6
之后还引入了偏向锁。
偏向锁的特征是:锁不存在多线程竞争,并且应由一个线程多次获得锁。
当线程访问同步块时,会使用 CAS
将线程 ID 更新到锁对象的 Mark Word
中,如果更新成功则获得偏向锁,并且之后每次进入这个对象锁相关的同步块时都不需要再次获取锁了。
释放锁
当有另外一个线程获取这个锁时,持有偏向锁的线程就会释放锁,释放时会等待全局安全点(这一时刻没有字节码运行),接着会暂停拥有偏向锁的线程,根据锁对象目前是否被锁来判定将对象头中的 Mark Word
设置为无锁或者是轻量锁状态。
轻量锁可以提高带有同步却没有竞争的程序性能,但如果程序中大多数锁都存在竞争时,那偏向锁就起不到太大作用。可以使用 -XX:-userBiasedLocking=false
来关闭偏向锁,并默认进入轻量锁。
其他优化
适应性自旋
在使用 CAS
时,如果操作失败,CAS
会自旋再次尝试。由于自旋是需要消耗 CPU
资源的,所以如果长期自旋就白白浪费了 CPU
。JDK1.6
加入了适应性自旋:
如果某个锁自旋很少成功获得,那么下一次就会减少自旋。
---------------------------------------------------------------------------
ReentrantLock 实现原理
使用 `synchronize` 来做同步处理时,锁的获取和释放都是隐式的,
实现的原理是通过编译后加上不同的机器指令来实现。
而 ReentrantLock
就是一个普通的类,它是基于 AQS(AbstractQueuedSynchronizer)
来实现的。
是一个重入锁:一个线程获得了锁之后仍然可以反复的加锁,不会出现自己阻塞自己的情况。
AQS
是Java
并发包里实现锁、同步的一个重要的基础框架。
锁类型
ReentrantLock 分为公平锁和非公平锁,可以通过构造方法来指定具体类型:
//默认非公平锁
public ReentrantLock() {
sync = new NonfairSync();
}
//公平锁
public ReentrantLock(boolean fair) {
sync = fair ? new FairSync() : new NonfairSync();
}
默认一般使用非公平锁,它的效率和吞吐量都比公平锁高的多(后面会分析具体原因)。
获取锁
通常的使用方式如下:
private ReentrantLock lock = new ReentrantLock();
public void run() {
lock.lock();
try {
//do bussiness
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
lock.unlock();
}
}
公平锁获取锁
首先看下获取锁的过程:
public void lock() {
sync.lock();
}
可以看到是使用 sync
的方法,而这个方法是一个抽象方法,具体是由其子类(FairSync
)来实现的,以下是公平锁的实现:
final void lock() {
acquire(1);
}
//AbstractQueuedSynchronizer 中的 acquire()
public final void acquire(int arg) {
if (!tryAcquire(arg) &&
acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
selfInterrupt();
}
第一步是尝试获取锁(tryAcquire(arg)
),这个也是由其子类实现:
protected final boolean tryAcquire(int acquires) {
final Thread current = Thread.currentThread();
int c = getState();
if (c == 0) {
if (!hasQueuedPredecessors() &&
compareAndSetState(0, acquires)) {
setExclusiveOwnerThread(current);
return true;
}
}
else if (current == getExclusiveOwnerThread()) {
int nextc = c + acquires;
if (nextc < 0)
throw new Error("Maximum lock count exceeded");
setState(nextc);
return true;
}
return false;
}
}
首先会判断 AQS
中的 state
是否等于 0,0 表示目前没有其他线程获得锁,当前线程就可以尝试获取锁。
注意:尝试之前会利用 hasQueuedPredecessors()
方法来判断 AQS 的队列中中是否有其他线程,如果有则不会尝试获取锁(这是公平锁特有的情况)。
如果队列中没有线程就利用 CAS 来将 AQS 中的 state 修改为1,也就是获取锁,获取成功则将当前线程置为获得锁的独占线程(setExclusiveOwnerThread(current)
)。
如果 state
大于 0 时,说明锁已经被获取了,则需要判断获取锁的线程是否为当前线程(ReentrantLock
支持重入),是则需要将 state + 1
,并将值更新。
写入队列
如果 tryAcquire(arg)
获取锁失败,则需要用 addWaiter(Node.EXCLUSIVE)
将当前线程写入队列中。
写入之前需要将当前线程包装为一个 Node
对象(addWaiter(Node.EXCLUSIVE)
)。
AQS 中的队列是由 Node 节点组成的双向链表实现的。
包装代码:
private Node addWaiter(Node mode) {
Node node = new Node(Thread.currentThread(), mode);
// Try the fast path of enq; backup to full enq on failure
Node pred = tail;
if (pred != null) {
node.prev = pred;
if (compareAndSetTail(pred, node)) {
pred.next = node;
return node;
}
}
enq(node);
return node;
}
首先判断队列是否为空,不为空时则将封装好的 Node
利用 CAS
写入队尾,如果出现并发写入失败就需要调用 enq(node);
来写入了。
private Node enq(final Node node) {
for (;;) {
Node t = tail;
if (t == null) { // Must initialize
if (compareAndSetHead(new Node()))
tail = head;
} else {
node.prev = t;
if (compareAndSetTail(t, node)) {
t.next = node;
return t;
}
}
}
}
这个处理逻辑就相当于自旋
加上 CAS
保证一定能写入队列。
挂起等待线程
写入队列之后需要将当前线程挂起(利用acquireQueued(addWaiter(Node.EXCLUSIVE), arg)
):
final boolean acquireQueued(final Node node, int arg) {
boolean failed = true;
try {
boolean interrupted = false;
for (;;) {
final Node p = node.predecessor();
if (p == head && tryAcquire(arg)) {
setHead(node);
p.next = null; // help GC
failed = false;
return interrupted;
}
if (shouldParkAfterFailedAcquire(p, node) &&
parkAndCheckInterrupt())
interrupted = true;
}
} finally {
if (failed)
cancelAcquire(node);
}
}
首先会根据 node.predecessor()
获取到上一个节点是否为头节点,如果是则尝试获取一次锁,获取成功就万事大吉了。
如果不是头节点,或者获取锁失败,则会根据上一个节点的 waitStatus
状态来处理(shouldParkAfterFailedAcquire(p, node)
)。
waitStatus
用于记录当前节点的状态,如节点取消、节点等待等。
shouldParkAfterFailedAcquire(p, node)
返回当前线程是否需要挂起,如果需要则调用 parkAndCheckInterrupt()
:
private final boolean parkAndCheckInterrupt() {
LockSupport.park(this);
return Thread.interrupted();
}
他是利用 LockSupport
的 part
方法来挂起当前线程的,直到被唤醒。
非公平锁获取锁
公平锁与非公平锁的差异主要在获取锁:
公平锁就相当于买票,后来的人需要排到队尾依次买票,不能插队。
而非公平锁则没有这些规则,是抢占模式,每来一个人不会去管队列如何,直接尝试获取锁。
非公平锁:
final void lock() {
//直接尝试获取锁
if (compareAndSetState(0, 1))
setExclusiveOwnerThread(Thread.currentThread());
else
acquire(1);
}
公平锁:
final void lock() {
acquire(1);
}
还要一个重要的区别是在尝试获取锁时tryAcquire(arg)
,非公平锁是不需要判断队列中是否还有其他线程,也是直接尝试获取锁:
final boolean nonfairTryAcquire(int acquires) {
final Thread current = Thread.currentThread();
int c = getState();
if (c == 0) {
//没有 !hasQueuedPredecessors() 判断
if (compareAndSetState(0, acquires)) {
setExclusiveOwnerThread(current);
return true;
}
}
else if (current == getExclusiveOwnerThread()) {
int nextc = c + acquires;
if (nextc < 0) // overflow
throw new Error("Maximum lock count exceeded");
setState(nextc);
return true;
}
return false;
}
释放锁
公平锁和非公平锁的释放流程都是一样的:
public void unlock() {
sync.release(1);
}
public final boolean release(int arg) {
if (tryRelease(arg)) {
Node h = head;
if (h != null && h.waitStatus != 0)
//唤醒被挂起的线程
unparkSuccessor(h);
return true;
}
return false;
}
//尝试释放锁
protected final boolean tryRelease(int releases) {
int c = getState() - releases;
if (Thread.currentThread() != getExclusiveOwnerThread())
throw new IllegalMonitorStateException();
boolean free = false;
if (c == 0) {
free = true;
setExclusiveOwnerThread(null);
}
setState(c);
return free;
}
首先会判断当前线程是否为获得锁的线程,由于是重入锁所以需要将 state
减到 0 才认为完全释放锁。
释放之后需要调用 unparkSuccessor(h)
来唤醒被挂起的线程。
总结
由于公平锁需要关心队列的情况,得按照队列里的先后顺序来获取锁(会造成大量的线程上下文切换),而非公平锁则没有这个限制。
所以也就能解释非公平锁的效率会被公平锁更高。
---------------------------------------------------------------------------
序列化和反序列化
摘要
序列化和反序列化几乎是工程师们每天都要面对的事情,但是要精确掌握这两个概念并不容易:一方面,它们往往作为框架的一部分出现而湮没在框架之中;另一方面,它们会以其他更容易理解的概念出现,例如加密、持久化。然而,序列化和反序列化的选型却是系统设计或重构一个重要的环节,在分布式、大数据量系统设计里面更为显著。恰当的序列化协议不仅可以提高系统的通用性、强健性、安全性、优化系统性能,而且会让系统更加易于调试、便于扩展。本文从多个角度去分析和讲解“序列化和反序列化”,并对比了当前流行的几种序列化协议,期望对读者做序列化选型有所帮助。
简介
文章作者服务于美团推荐与个性化组,该组致力于为美团用户提供每天billion级别的高质量个性化推荐以及排序服务。从Terabyte级别的用户行为数据,到Gigabyte级别的Deal/Poi数据;从对实时性要求毫秒以内的用户实时地理位置数据,到定期后台job数据,推荐与重排序系统需要多种类型的数据服务。推荐与重排序系统客户包括各种内部服务、美团客户端、美团网站。为了提供高质量的数据服务,为了实现与上下游各系统进行良好的对接,序列化和反序列化的选型往往是我们做系统设计的一个重要考虑因素。
本文内容按如下方式组织:
- 第一部分给出了序列化和反序列化的定义,以及其在通讯协议中所处的位置。
- 第二部分从使用者的角度探讨了序列化协议的一些特性。
- 第三部分描述在具体的实施过程中典型的序列化组件,并与数据库组建进行了类比。
- 第四部分分别讲解了目前常见的几种序列化协议的特性,应用场景,并对相关组件进行举例。
- 最后一部分,基于各种协议的特性,以及相关benchmark数据,给出了作者的技术选型建议。
#一、定义以及相关概念
互联网的产生带来了机器间通讯的需求,而互联通讯的双方需要采用约定的协议,序列化和反序列化属于通讯协议的一部分。通讯协议往往采用分层模型,不同模型每层的功能定义以及颗粒度不同,例如:TCP/IP协议是一个四层协议,而OSI模型却是七层协议模型。在OSI七层协议模型中展现层(Presentation Layer)的主要功能是把应用层的对象转换成一段连续的二进制串,或者反过来,把二进制串转换成应用层的对象--这两个功能就是序列化和反序列化。一般而言,TCP/IP协议的应用层对应与OSI七层协议模型的应用层,展示层和会话层,所以序列化协议属于TCP/IP协议应用层的一部分。本文对序列化协议的讲解主要基于OSI七层协议模型。
- 序列化: 将数据结构或对象转换成二进制串的过程
- 反序列化:将在序列化过程中所生成的二进制串转换成数据结构或者对象的过程
数据结构、对象与二进制串
不同的计算机语言中,数据结构,对象以及二进制串的表示方式并不相同。
数据结构和对象:对于类似Java这种完全面向对象的语言,工程师所操作的一切都是对象(Object),来自于类的实例化。在Java语言中最接近数据结构的概念,就是POJO(Plain Old Java Object)或者Javabean--那些只有setter/getter方法的类。而在C++这种半面向对象的语言中,数据结构和struct对应,对象和class对应。
二进制串:序列化所生成的二进制串指的是存储在内存中的一块数据。C++语言具有内存操作符,所以二进制串的概念容易理解,例如,C++语言的字符串可以直接被传输层使用,因为其本质上就是以'\0'结尾的存储在内存中的二进制串。在Java语言里面,二进制串的概念容易和String混淆。实际上String 是Java的一等公民,是一种特殊对象(Object)。对于跨语言间的通讯,序列化后的数据当然不能是某种语言的特殊数据类型。二进制串在Java里面所指的是byte[],byte是Java的8中原生数据类型之一(Primitive data types)。
#二、序列化协议特性
每种序列化协议都有优点和缺点,它们在设计之初有自己独特的应用场景。在系统设计的过程中,需要考虑序列化需求的方方面面,综合对比各种序列化协议的特性,最终给出一个折衷的方案。
通用性
通用性有两个层面的意义:
第一、技术层面,序列化协议是否支持跨平台、跨语言。如果不支持,在技术层面上的通用性就大大降低了。
第二、流行程度,序列化和反序列化需要多方参与,很少人使用的协议往往意味着昂贵的学习成本;另一方面,流行度低的协议,往往缺乏稳定而成熟的跨语言、跨平台的公共包。
强健性/鲁棒性
以下两个方面的原因会导致协议不够强健:
第一、成熟度不够,一个协议从制定到实施,到最后成熟往往是一个漫长的阶段。协议的强健性依赖于大量而全面的测试,对于致力于提供高质量服务的系统,采用处于测试阶段的序列化协议会带来很高的风险。
第二、语言/平台的不公平性。为了支持跨语言、跨平台的功能,序列化协议的制定者需要做大量的工作;但是,当所支持的语言或者平台之间存在难以调和的特性的时候,协议制定者需要做一个艰难的决定--支持更多人使用的语言/平台,亦或支持更多的语言/平台而放弃某个特性。当协议的制定者决定为某种语言或平台提供更多支持的时候,对于使用者而言,协议的强健性就被牺牲了。
可调试性/可读性
序列化和反序列化的数据正确性和业务正确性的调试往往需要很长的时间,良好的调试机制会大大提高开发效率。序列化后的二进制串往往不具备人眼可读性,为了验证序列化结果的正确性,写入方不得同时撰写反序列化程序,或提供一个查询平台--这比较费时;另一方面,如果读取方未能成功实现反序列化,这将给问题查找带来了很大的挑战--难以定位是由于自身的反序列化程序的bug所导致还是由于写入方序列化后的错误数据所导致。对于跨公司间的调试,由于以下原因,问题会显得更严重:
第一、支持不到位,跨公司调试在问题出现后可能得不到及时的支持,这大大延长了调试周期。
第二、访问限制,调试阶段的查询平台未必对外公开,这增加了读取方的验证难度。
如果序列化后的数据人眼可读,这将大大提高调试效率, XML和JSON就具有人眼可读的优点。
性能
性能包括两个方面,时间复杂度和空间复杂度:
第一、空间开销(Verbosity), 序列化需要在原有的数据上加上描述字段,以为反序列化解析之用。如果序列化过程引入的额外开销过高,可能会导致过大的网络,磁盘等各方面的压力。对于海量分布式存储系统,数据量往往以TB为单位,巨大的的额外空间开销意味着高昂的成本。
第二、时间开销(Complexity),复杂的序列化协议会导致较长的解析时间,这可能会使得序列化和反序列化阶段成为整个系统的瓶颈。
可扩展性/兼容性
移动互联时代,业务系统需求的更新周期变得更快,新的需求不断涌现,而老的系统还是需要继续维护。如果序列化协议具有良好的可扩展性,支持自动增加新的业务字段,而不影响老的服务,这将大大提供系统的灵活度。
安全性/访问限制
在序列化选型的过程中,安全性的考虑往往发生在跨局域网访问的场景。当通讯发生在公司之间或者跨机房的时候,出于安全的考虑,对于跨局域网的访问往往被限制为基于HTTP/HTTPS的80和443端口。如果使用的序列化协议没有兼容而成熟的HTTP传输层框架支持,可能会导致以下三种结果之一:
第一、因为访问限制而降低服务可用性。
第二、被迫重新实现安全协议而导致实施成本大大提高。
第三、开放更多的防火墙端口和协议访问,而牺牲安全性。
#三、序列化和反序列化的组件
典型的序列化和反序列化过程往往需要如下组件:
- IDL(Interface description language)文件:参与通讯的各方需要对通讯的内容需要做相关的约定(Specifications)。为了建立一个与语言和平台无关的约定,这个约定需要采用与具体开发语言、平台无关的语言来进行描述。这种语言被称为接口描述语言(IDL),采用IDL撰写的协议约定称之为IDL文件。
- IDL Compiler:IDL文件中约定的内容为了在各语言和平台可见,需要有一个编译器,将IDL文件转换成各语言对应的动态库。
- Stub/Skeleton Lib:负责序列化和反序列化的工作代码。Stub是一段部署在分布式系统客户端的代码,一方面接收应用层的参数,并对其序列化后通过底层协议栈发送到服务端,另一方面接收服务端序列化后的结果数据,反序列化后交给客户端应用层;Skeleton部署在服务端,其功能与Stub相反,从传输层接收序列化参数,反序列化后交给服务端应用层,并将应用层的执行结果序列化后最终传送给客户端Stub。
- Client/Server:指的是应用层程序代码,他们面对的是IDL所生存的特定语言的class或struct。
- 底层协议栈和互联网:序列化之后的数据通过底层的传输层、网络层、链路层以及物理层协议转换成数字信号在互联网中传递。
序列化组件与数据库访问组件的对比
数据库访问对于很多工程师来说相对熟悉,所用到的组件也相对容易理解。下表类比了序列化过程中用到的部分组件和数据库访问组件的对应关系,以便于大家更好的把握序列化相关组件的概念。
序列化组件 | 数据库组件 | 说明 |
---|---|---|
IDL | DDL | 用于建表或者模型的语言 |
DL file | DB Schema | 表创建文件或模型文件 |
Stub/Skeleton lib | O/R mapping | 将class和Table或者数据模型进行映射 |
几种常见的序列化和反序列化协议
互联网早期的序列化协议主要有COM和CORBA。
COM主要用于Windows平台,并没有真正实现跨平台,另外COM的序列化的原理利用了编译器中虚表,使得其学习成本巨大(想一下这个场景, 工程师需要是简单的序列化协议,但却要先掌握语言编译器)。由于序列化的数据与编译器紧耦合,扩展属性非常麻烦。
CORBA是早期比较好的实现了跨平台,跨语言的序列化协议。COBRA的主要问题是参与方过多带来的版本过多,版本之间兼容性较差,以及使用复杂晦涩。这些政治经济,技术实现以及早期设计不成熟的问题,最终导致COBRA的渐渐消亡。J2SE 1.3之后的版本提供了基于CORBA协议的RMI-IIOP技术,这使得Java开发者可以采用纯粹的Java语言进行CORBA的开发。
这里主要介绍和对比几种当下比较流行的序列化协议,包括XML、JSON、Protobuf、Thrift和Avro。
一个例子
如前所述,序列化和反序列化的出现往往晦涩而隐蔽,与其他概念之间往往相互包容。为了更好了让大家理解序列化和反序列化的相关概念在每种协议里面的具体实现,我们将一个例子穿插在各种序列化协议讲解中。在该例子中,我们希望将一个用户信息在多个系统里面进行传递;在应用层,如果采用Java语言,所面对的类对象如下所示:
class Address
{
private String city;
private String postcode;
private String street;
}
public class UserInfo
{
private Integer userid;
private String name;
private List<Address> address;
}
XML&SOAP
XML是一种常用的序列化和反序列化协议,具有跨机器,跨语言等优点。 XML历史悠久,其1.0版本早在1998年就形成标准,并被广泛使用至今。XML的最初产生目标是对互联网文档(Document)进行标记,所以它的设计理念中就包含了对于人和机器都具备可读性。 但是,当这种标记文档的设计被用来序列化对象的时候,就显得冗长而复杂(Verbose and Complex)。 XML本质上是一种描述语言,并且具有自我描述(Self-describing)的属性,所以XML自身就被用于XML序列化的IDL。 标准的XML描述格式有两种:DTD(Document Type Definition)和XSD(XML Schema Definition)。作为一种人眼可读(Human-readable)的描述语言,XML被广泛使用在配置文件中,例如O/R mapping、 Spring Bean Configuration File 等。
SOAP(Simple Object Access protocol) 是一种被广泛应用的,基于XML为序列化和反序列化协议的结构化消息传递协议。SOAP在互联网影响如此大,以至于我们给基于SOAP的解决方案一个特定的名称--Web service。SOAP虽然可以支持多种传输层协议,不过SOAP最常见的使用方式还是XML+HTTP。SOAP协议的主要接口描述语言(IDL)是WSDL(Web Service Description Language)。SOAP具有安全、可扩展、跨语言、跨平台并支持多种传输层协议。如果不考虑跨平台和跨语言的需求,XML的在某些语言里面具有非常简单易用的序列化使用方法,无需IDL文件和第三方编译器, 例如Java+XStream。
自我描述与递归
SOAP是一种采用XML进行序列化和反序列化的协议,它的IDL是WSDL. 而WSDL的描述文件是XSD,而XSD自身是一种XML文件。 这里产生了一种有趣的在数学上称之为“递归”的问题,这种现象往往发生在一些具有自我属性(Self-description)的事物上。
IDL文件举例
采用WSDL描述上述用户基本信息的例子如下:
<xsd:complexType name='Address'>
<xsd:attribute name='city' type='xsd:string' />
<xsd:attribute name='postcode' type='xsd:string' />
<xsd:attribute name='street' type='xsd:string' />
</xsd:complexType>
<xsd:complexType name='UserInfo'>
<xsd:sequence>
<xsd:element name='address' type='tns:Address'/>
<xsd:element name='address1' type='tns:Address'/>
</xsd:sequence>
<xsd:attribute name='userid' type='xsd:int' />
<xsd:attribute name='name' type='xsd:string' />
</xsd:complexType>
典型应用场景和非应用场景
SOAP协议具有广泛的群众基础,基于HTTP的传输协议使得其在穿越防火墙时具有良好安全特性,XML所具有的人眼可读(Human-readable)特性使得其具有出众的可调试性,互联网带宽的日益剧增也大大弥补了其空间开销大(Verbose)的缺点。对于在公司之间传输数据量相对小或者实时性要求相对低(例如秒级别)的服务是一个好的选择。
由于XML的额外空间开销大,序列化之后的数据量剧增,对于数据量巨大序列持久化应用常景,这意味着巨大的内存和磁盘开销,不太适合XML。另外,XML的序列化和反序列化的空间和时间开销都比较大,对于对性能要求在ms级别的服务,不推荐使用。WSDL虽然具备了描述对象的能力,SOAP的S代表的也是simple,但是SOAP的使用绝对不简单。对于习惯于面向对象编程的用户,WSDL文件不直观。
JSON(Javascript Object Notation)
JSON起源于弱类型语言Javascript, 它的产生来自于一种称之为"Associative array"的概念,其本质是就是采用"Attribute-value"的方式来描述对象。实际上在Javascript和PHP等弱类型语言中,类的描述方式就是Associative array。JSON的如下优点,使得它快速成为最广泛使用的序列化协议之一:
1、这种Associative array格式非常符合工程师对对象的理解。
2、它保持了XML的人眼可读(Human-readable)的优点。
3、相对于XML而言,序列化后的数据更加简洁。 来自于的以下链接的研究表明:XML所产生序列化之后文件的大小接近JSON的两倍。http://www.codeproject.com/Articles/604720/JSON-vs-XML-Some-hard-numbers-about-verbosity
4、它具备Javascript的先天性支持,所以被广泛应用于Web browser的应用常景中,是Ajax的事实标准协议。
5、与XML相比,其协议比较简单,解析速度比较快。
6、松散的Associative array使得其具有良好的可扩展性和兼容性。
IDL悖论
JSON实在是太简单了,或者说太像各种语言里面的类了,所以采用JSON进行序列化不需要IDL。这实在是太神奇了,存在一种天然的序列化协议,自身就实现了跨语言和跨平台。然而事实没有那么神奇,之所以产生这种假象,来自于两个原因:
第一、Associative array在弱类型语言里面就是类的概念,在PHP和Javascript里面Associative array就是其class的实际实现方式,所以在这些弱类型语言里面,JSON得到了非常良好的支持。
第二、IDL的目的是撰写IDL文件,而IDL文件被IDL Compiler编译后能够产生一些代码(Stub/Skeleton),而这些代码是真正负责相应的序列化和反序列化工作的组件。 但是由于Associative array和一般语言里面的class太像了,他们之间形成了一一对应关系,这就使得我们可以采用一套标准的代码进行相应的转化。对于自身支持Associative array的弱类型语言,语言自身就具备操作JSON序列化后的数据的能力;对于Java这强类型语言,可以采用反射的方式统一解决,例如Google提供的Gson。
典型应用场景和非应用场景
JSON在很多应用场景中可以替代XML,更简洁并且解析速度更快。典型应用场景包括:
1、公司之间传输数据量相对小,实时性要求相对低(例如秒级别)的服务。
2、基于Web browser的Ajax请求。
3、由于JSON具有非常强的前后兼容性,对于接口经常发生变化,并对可调式性要求高的场景,例如Mobile app与服务端的通讯。
4、由于JSON的典型应用场景是JSON+HTTP,适合跨防火墙访问。
总的来说,采用JSON进行序列化的额外空间开销比较大,对于大数据量服务或持久化,这意味着巨大的内存和磁盘开销,这种场景不适合。没有统一可用的IDL降低了对参与方的约束,实际操作中往往只能采用文档方式来进行约定,这可能会给调试带来一些不便,延长开发周期。 由于JSON在一些语言中的序列化和反序列化需要采用反射机制,所以在性能要求为ms级别,不建议使用。
IDL文件举例
以下是UserInfo序列化之后的一个例子:
{"userid":1,"name":"messi","address":[{"city":"北京","postcode":"1000000","street":"wangjingdonglu"}]}
Thrift
Thrift是Facebook开源提供的一个高性能,轻量级RPC服务框架,其产生正是为了满足当前大数据量、分布式、跨语言、跨平台数据通讯的需求。 但是,Thrift并不仅仅是序列化协议,而是一个RPC框架。相对于JSON和XML而言,Thrift在空间开销和解析性能上有了比较大的提升,对于对性能要求比较高的分布式系统,它是一个优秀的RPC解决方案;但是由于Thrift的序列化被嵌入到Thrift框架里面,Thrift框架本身并没有透出序列化和反序列化接口,这导致其很难和其他传输层协议共同使用(例如HTTP)。
典型应用场景和非应用场景
对于需求为高性能,分布式的RPC服务,Thrift是一个优秀的解决方案。它支持众多语言和丰富的数据类型,并对于数据字段的增删具有较强的兼容性。所以非常适用于作为公司内部的面向服务构建(SOA)的标准RPC框架。
不过Thrift的文档相对比较缺乏,目前使用的群众基础相对较少。另外由于其Server是基于自身的Socket服务,所以在跨防火墙访问时,安全是一个顾虑,所以在公司间进行通讯时需要谨慎。 另外Thrift序列化之后的数据是Binary数组,不具有可读性,调试代码时相对困难。最后,由于Thrift的序列化和框架紧耦合,无法支持向持久层直接读写数据,所以不适合做数据持久化序列化协议。
IDL文件举例
struct Address
{
1: required string city;
2: optional string postcode;
3: optional string street;
}
struct UserInfo
{
1: required string userid;
2: required i32 name;
3: optional list<Address> address;
}
Protobuf
Protobuf具备了优秀的序列化协议的所需的众多典型特征:
1、标准的IDL和IDL编译器,这使得其对工程师非常友好。
2、序列化数据非常简洁,紧凑,与XML相比,其序列化之后的数据量约为1/3到1/10。
3、解析速度非常快,比对应的XML快约20-100倍。
4、提供了非常友好的动态库,使用非常简介,反序列化只需要一行代码。
Protobuf是一个纯粹的展示层协议,可以和各种传输层协议一起使用;Protobuf的文档也非常完善。 但是由于Protobuf产生于Google,所以目前其仅仅支持Java、C++、Python三种语言。另外Protobuf支持的数据类型相对较少,不支持常量类型。由于其设计的理念是纯粹的展现层协议(Presentation Layer),目前并没有一个专门支持Protobuf的RPC框架。
典型应用场景和非应用场景
Protobuf具有广泛的用户基础,空间开销小以及高解析性能是其亮点,非常适合于公司内部的对性能要求高的RPC调用。由于Protobuf提供了标准的IDL以及对应的编译器,其IDL文件是参与各方的非常强的业务约束,另外,Protobuf与传输层无关,采用HTTP具有良好的跨防火墙的访问属性,所以Protobuf也适用于公司间对性能要求比较高的场景。由于其解析性能高,序列化后数据量相对少,非常适合应用层对象的持久化场景。
它的主要问题在于其所支持的语言相对较少,另外由于没有绑定的标准底层传输层协议,在公司间进行传输层协议的调试工作相对麻烦。
IDL文件举例
message Address
{
required string city=1;
optional string postcode=2;
optional string street=3;
}
message UserInfo
{
required string userid=1;
required string name=2;
repeated Address address=3;
}
Avro
Avro的产生解决了JSON的冗长和没有IDL的问题,Avro属于Apache Hadoop的一个子项目。 Avro提供两种序列化格式:JSON格式或者Binary格式。Binary格式在空间开销和解析性能方面可以和Protobuf媲美,JSON格式方便测试阶段的调试。 Avro支持的数据类型非常丰富,包括C++语言里面的union类型。Avro支持JSON格式的IDL和类似于Thrift和Protobuf的IDL(实验阶段),这两者之间可以互转。Schema可以在传输数据的同时发送,加上JSON的自我描述属性,这使得Avro非常适合动态类型语言。 Avro在做文件持久化的时候,一般会和Schema一起存储,所以Avro序列化文件自身具有自我描述属性,所以非常适合于做Hive、Pig和MapReduce的持久化数据格式。对于不同版本的Schema,在进行RPC调用的时候,服务端和客户端可以在握手阶段对Schema进行互相确认,大大提高了最终的数据解析速度。
典型应用场景和非应用场景
Avro解析性能高并且序列化之后的数据非常简洁,比较适合于高性能的序列化服务。
由于Avro目前非JSON格式的IDL处于实验阶段,而JSON格式的IDL对于习惯于静态类型语言的工程师来说不直观。
IDL文件举例
protocol Userservice {
record Address {
string city;
string postcode;
string street;
}
record UserInfo {
string name;
int userid;
array<Address> address = [];
}
}
所对应的JSON Schema格式如下:
{
"protocol" : "Userservice",
"namespace" : "org.apache.avro.ipc.specific",
"version" : "1.0.5",
"types" : [ {
"type" : "record",
"name" : "Address",
"fields" : [ {
"name" : "city",
"type" : "string"
}, {
"name" : "postcode",
"type" : "string"
}, {
"name" : "street",
"type" : "string"
} ]
}, {
"type" : "record",
"name" : "UserInfo",
"fields" : [ {
"name" : "name",
"type" : "string"
}, {
"name" : "userid",
"type" : "int"
}, {
"name" : "address",
"type" : {
"type" : "array",
"items" : "Address"
},
"default" : [ ]
} ]
} ],
"messages" : { }
}
#五、Benchmark以及选型建议
##Benchmark
以下数据来自https://code.google.com/p/thrift-protobuf-compare/wiki/Benchmarking
解析性能
序列化之空间开销
从上图可得出如下结论:
1、XML序列化(Xstream)无论在性能和简洁性上比较差。
2、Thrift与Protobuf相比在时空开销方面都有一定的劣势。
3、Protobuf和Avro在两方面表现都非常优越。
选型建议
以上描述的五种序列化和反序列化协议都各自具有相应的特点,适用于不同的场景:
1、对于公司间的系统调用,如果性能要求在100ms以上的服务,基于XML的SOAP协议是一个值得考虑的方案。
2、基于Web browser的Ajax,以及Mobile app与服务端之间的通讯,JSON协议是首选。对于性能要求不太高,或者以动态类型语言为主,或者传输数据载荷很小的的运用场景,JSON也是非常不错的选择。
3、对于调试环境比较恶劣的场景,采用JSON或XML能够极大的提高调试效率,降低系统开发成本。
4、当对性能和简洁性有极高要求的场景,Protobuf,Thrift,Avro之间具有一定的竞争关系。
5、对于T级别的数据的持久化应用场景,Protobuf和Avro是首要选择。如果持久化后的数据存储在Hadoop子项目里,Avro会是更好的选择。
6、由于Avro的设计理念偏向于动态类型语言,对于动态语言为主的应用场景,Avro是更好的选择。
7、对于持久层非Hadoop项目,以静态类型语言为主的应用场景,Protobuf会更符合静态类型语言工程师的开发习惯。
8、如果需要提供一个完整的RPC解决方案,Thrift是一个好的选择。
9、如果序列化之后需要支持不同的传输层协议,或者需要跨防火墙访问的高性能场景,Protobuf可以优先考虑。
---------------------------------------------------------------------------
transient
对象实现Serilizable接口,这个对象的所有属性和方法都会自动序列化。
举例:我们常常会遇到这样的问题,这个类的有些属性需要序列化,而其他属性不需要被序列化。如果一个用户有一些敏感信息(如密码,银行卡号等),为了安全起见,不希望在网络操作。
这些变量就可以加上transient关键字,那么它的生命周期仅存于调用者的内存中而不会写到磁盘里持久化。
一旦变量被transient修饰,变量将不再是对象持久化的一部分,该变量内容在序列化后无法获得访问。
transient关键字只能修饰变量,而不能修饰方法和类。
静态变量不管是否被transient修饰,均不能被序列化。
对象实现Externalizable接口,则没有任何东西可以自动序列化,需要在writeExternal方法中进行手工指定所要序列化的变量,这与是否被transient修饰无关。
---------------------------------------------------------------------------
volatile关键字
前言
我们知道volatile关键字的作用是保证变量在多线程之间的可见性,它是java.util.concurrent包的核心,没有volatile就没有这么多的并发类给我们使用。
本文详细解读一下volatile关键字如何保证变量在多线程之间的可见性,在此之前,有必要讲解一下CPU缓存的相关知识,掌握这部分知识一定会让我们更好地理解volatile的原理,从而更好、更正确地地使用volatile关键字。
CPU缓存
CPU缓存的出现主要是为了解决CPU运算速度与内存读写速度不匹配的矛盾,因为CPU运算速度要比内存读写速度快得多,举个例子:
- 一次主内存的访问通常在几十到几百个时钟周期
- 一次L1高速缓存的读写只需要1~2个时钟周期
- 一次L2高速缓存的读写也只需要数十个时钟周期
这种访问速度的显著差异,导致CPU可能会花费很长时间等待数据到来或把数据写入内存。
基于此,现在CPU大多数情况下读写都不会直接访问内存(CPU都没有连接到内存的管脚),取而代之的是CPU缓存,CPU缓存是位于CPU与内存之间的临时存储器,它的容量比内存小得多但是交换速度却比内存快得多。而缓存中的数据是内存中的一小部分数据,但这一小部分是短时间内CPU即将访问的,当CPU调用大量数据时,就可先从缓存中读取,从而加快读取速度。
按照读取顺序与CPU结合的紧密程度,CPU缓存可分为:
- 一级缓存:简称L1 Cache,位于CPU内核的旁边,是与CPU结合最为紧密的CPU缓存
- 二级缓存:简称L2 Cache,分内部和外部两种芯片,内部芯片二级缓存运行速度与主频相同,外部芯片二级缓存运行速度则只有主频的一半
- 三级缓存:简称L3 Cache,部分高端CPU才有
每一级缓存中所存储的数据全部都是下一级缓存中的一部分,这三种缓存的技术难度和制造成本是相对递减的,所以其容量也相对递增。
当CPU要读取一个数据时,首先从一级缓存中查找,如果没有再从二级缓存中查找,如果还是没有再从三级缓存中或内存中查找。一般来说每级缓存的命中率大概都有80%左右,也就是说全部数据量的80%都可以在一级缓存中找到,只剩下20%的总数据量才需要从二级缓存、三级缓存或内存中读取。
使用CPU缓存带来的问题
用一张图表示一下CPU-->CPU缓存-->主内存数据读取之间的关系:
当系统运行时,CPU执行计算的过程如下:
- 程序以及数据被加载到主内存
- 指令和数据被加载到CPU缓存
- CPU执行指令,把结果写到高速缓存
- 高速缓存中的数据写回主内存
如果服务器是单核CPU,那么这些步骤不会有任何的问题,但是如果服务器是多核CPU,那么问题来了,以Intel Core i7处理器的高速缓存概念模型为例(图片摘自《深入理解计算机系统》):
试想下面一种情况:
- 核0读取了一个字节,根据局部性原理,它相邻的字节同样被被读入核0的缓存
- 核3做了上面同样的工作,这样核0与核3的缓存拥有同样的数据
- 核0修改了那个字节,被修改后,那个字节被写回核0的缓存,但是该信息并没有写回主存
- 核3访问该字节,由于核0并未将数据写回主存,数据不同步
为了解决这个问题,CPU制造商制定了一个规则:当一个CPU修改缓存中的字节时,服务器中其他CPU会被通知,它们的缓存将视为无效。于是,在上面的情况下,核3发现自己的缓存中数据已无效,核0将立即把自己的数据写回主存,然后核3重新读取该数据。
反汇编Java字节码,查看汇编层面对volatile关键字做了什么
有了上面的理论基础,我们可以研究volatile关键字到底是如何实现的。首先写一段简单的代码:
1 /** 2 * @author 五月的仓颉http://www.cnblogs.com/xrq730/p/7048693.html 3 */ 4 public class LazySingleton { 5 6 private static volatile LazySingleton instance = null; 7 8 public static LazySingleton getInstance() { 9 if (instance == null) { 10 instance = new LazySingleton(); 11 } 12 13 return instance; 14 } 15 16 public static void main(String[] args) { 17 LazySingleton.getInstance(); 18 } 19 20 }
首先反编译一下这段代码的.class文件,看一下生成的字节码:
没有任何特别的。要知道,字节码指令,比如上图的getstatic、ifnonnull、new等,最终对应到操作系统的层面,都是转换为一条一条指令去执行,我们使用的PC机、应用服务器的CPU架构通常都是IA-32架构的,这种架构采用的指令集是CISC(复杂指令集),而汇编语言则是这种指令集的助记符。
因此,既然在字节码层面我们看不出什么端倪,那下面就看看将代码转换为汇编指令能看出什么端倪。Windows上要看到以上代码对应的汇编码不难(吐槽一句,说说不难,为了这个问题我找遍了各种资料,差点就准备安装虚拟机,在Linux系统上搞了),访问hsdis工具路径可直接下载hsdis工具,下载完毕之后解压,将hsdis-amd64.dll与hsdis-amd64.lib两个文件放在%JAVA_HOME%\jre\bin\server路径下即可,如下图:
然后跑main函数,跑main函数之前,加入如下虚拟机参数:
-server -Xcomp -XX:+UnlockDiagnosticVMOptions -XX:+PrintAssembly -XX:CompileCommand=compileonly,*LazySingleton.getInstance
运行main函数即可,代码生成的汇编指令为:
1 Java HotSpot(TM) 64-Bit Server VM warning: PrintAssembly is enabled; turning on DebugNonSafepoints to gain additional output 2 CompilerOracle: compileonly *LazySingleton.getInstance 3 Loaded disassembler from D:\JDK\jre\bin\server\hsdis-amd64.dll 4 Decoding compiled method 0x0000000002931150: 5 Code: 6 Argument 0 is unknown.RIP: 0x29312a0 Code size: 0x00000108 7 [Disassembling for mach='amd64'] 8 [Entry Point] 9 [Verified Entry Point] 10 [Constants] 11 # {method} 'getInstance' '()Lorg/xrq/test/design/singleton/LazySingleton;' in 'org/xrq/test/design/singleton/LazySingleton' 12 # [sp+0x20] (sp of caller) 13 0x00000000029312a0: mov dword ptr [rsp+0ffffffffffffa000h],eax 14 0x00000000029312a7: push rbp 15 0x00000000029312a8: sub rsp,10h ;*synchronization entry 16 ; - org.xrq.test.design.singleton.LazySingleton::getInstance@-1 (line 13) 17 0x00000000029312ac: mov r10,7ada9e428h ; {oop(a 'java/lang/Class' = 'org/xrq/test/design/singleton/LazySingleton')} 18 0x00000000029312b6: mov r11d,dword ptr [r10+58h] 19 ;*getstatic instance 20 ; - org.xrq.test.design.singleton.LazySingleton::getInstance@0 (line 13) 21 0x00000000029312ba: test r11d,r11d 22 0x00000000029312bd: je 29312e0h 23 0x00000000029312bf: mov r10,7ada9e428h ; {oop(a 'java/lang/Class' = 'org/xrq/test/design/singleton/LazySingleton')} 24 0x00000000029312c9: mov r11d,dword ptr [r10+58h] 25 0x00000000029312cd: mov rax,r11 26 0x00000000029312d0: shl rax,3h ;*getstatic instance 27 ; - org.xrq.test.design.singleton.LazySingleton::getInstance@16 (line 17) 28 0x00000000029312d4: add rsp,10h 29 0x00000000029312d8: pop rbp 30 0x00000000029312d9: test dword ptr [330000h],eax ; {poll_return} 31 0x00000000029312df: ret 32 0x00000000029312e0: mov rax,qword ptr [r15+60h] 33 0x00000000029312e4: mov r10,rax 34 0x00000000029312e7: add r10,10h 35 0x00000000029312eb: cmp r10,qword ptr [r15+70h] 36 0x00000000029312ef: jnb 293135bh 37 0x00000000029312f1: mov qword ptr [r15+60h],r10 38 0x00000000029312f5: prefetchnta byte ptr [r10+0c0h] 39 0x00000000029312fd: mov r11d,0e07d00b2h ; {oop('org/xrq/test/design/singleton/LazySingleton')} 40 0x0000000002931303: mov r10,qword ptr [r12+r11*8+0b0h] 41 0x000000000293130b: mov qword ptr [rax],r10 42 0x000000000293130e: mov dword ptr [rax+8h],0e07d00b2h 43 ; {oop('org/xrq/test/design/singleton/LazySingleton')} 44 0x0000000002931315: mov dword ptr [rax+0ch],r12d 45 0x0000000002931319: mov rbp,rax ;*new ; - org.xrq.test.design.singleton.LazySingleton::getInstance@6 (line 14) 46 0x000000000293131c: mov rdx,rbp 47 0x000000000293131f: call 2907c60h ; OopMap{rbp=Oop off=132} 48 ;*invokespecial <init> 49 ; - org.xrq.test.design.singleton.LazySingleton::getInstance@10 (line 14) 50 ; {optimized virtual_call} 51 0x0000000002931324: mov r10,rbp 52 0x0000000002931327: shr r10,3h 53 0x000000000293132b: mov r11,7ada9e428h ; {oop(a 'java/lang/Class' = 'org/xrq/test/design/singleton/LazySingleton')} 54 0x0000000002931335: mov dword ptr [r11+58h],r10d 55 0x0000000002931339: mov r10,7ada9e428h ; {oop(a 'java/lang/Class' = 'org/xrq/test/design/singleton/LazySingleton')} 56 0x0000000002931343: shr r10,9h 57 0x0000000002931347: mov r11d,20b2000h 58 0x000000000293134d: mov byte ptr [r11+r10],r12l 59 0x0000000002931351: lock add dword ptr [rsp],0h ;*putstatic instance 60 ; - org.xrq.test.design.singleton.LazySingleton::getInstance@13 (line 14) 61 0x0000000002931356: jmp 29312bfh 62 0x000000000293135b: mov rdx,703e80590h ; {oop('org/xrq/test/design/singleton/LazySingleton')} 63 0x0000000002931365: nop 64 0x0000000002931367: call 292fbe0h ; OopMap{off=204} 65 ;*new ; - org.xrq.test.design.singleton.LazySingleton::getInstance@6 (line 14) 66 ; {runtime_call} 67 0x000000000293136c: jmp 2931319h 68 0x000000000293136e: mov rdx,rax 69 0x0000000002931371: jmp 2931376h 70 0x0000000002931373: mov rdx,rax ;*new ; - org.xrq.test.design.singleton.LazySingleton::getInstance@6 (line 14) 71 0x0000000002931376: add rsp,10h 72 0x000000000293137a: pop rbp 73 0x000000000293137b: jmp 2932b20h ; {runtime_call} 74 [Stub Code] 75 0x0000000002931380: mov rbx,0h ; {no_reloc} 76 0x000000000293138a: jmp 293138ah ; {runtime_call} 77 [Exception Handler] 78 0x000000000293138f: jmp 292fca0h ; {runtime_call} 79 [Deopt Handler Code] 80 0x0000000002931394: call 2931399h 81 0x0000000002931399: sub qword ptr [rsp],5h 82 0x000000000293139e: jmp 2909000h ; {runtime_call} 83 0x00000000029313a3: hlt 84 0x00000000029313a4: hlt 85 0x00000000029313a5: hlt 86 0x00000000029313a6: hlt 87 0x00000000029313a7: hlt
这么长长的汇编代码,可能大家不知道CPU在哪里做了手脚,没事不难,定位到59、60两行:
0x0000000002931351: lock add dword ptr [rsp],0h ;*putstatic instance ; - org.xrq.test.design.singleton.LazySingleton::getInstance@13 (line 14)
之所以定位到这两行是因为这里结尾写明了line 14,line 14即volatile变量instance赋值的地方。后面的add dword ptr [rsp],0h都是正常的汇编语句,意思是将双字节的栈指针寄存器+0,这里的关键就是add前面的lock指令,后面详细分析一下lock指令的作用和为什么加上lock指令后就能保证volatile关键字的内存可见性。
lock指令做了什么
之前有说过IA-32架构,关于CPU架构的问题大家有兴趣的可以自己查询一下,这里查询一下IA-32手册关于lock指令的描述,没有IA-32手册的可以去这个地址下载IA-32手册下载地址,是个中文版本的手册。
我摘抄一下IA-32手册中关于lock指令作用的一些描述(因为lock指令的作用在手册中散落在各处,并不是在某一章或者某一节专门讲):
在修改内存操作时,使用LOCK前缀去调用加锁的读-修改-写操作,这种机制用于多处理器系统中处理器之间进行可靠的通讯,具体描述如下: (1)在Pentium和早期的IA-32处理器中,LOCK前缀会使处理器执行当前指令时产生一个LOCK#信号,这种总是引起显式总线锁定出现 (2)在Pentium4、Inter Xeon和P6系列处理器中,加锁操作是由高速缓存锁或总线锁来处理。如果内存访问有高速缓存且只影响一个单独的高速缓存行,那么操作中就会调用高速缓存锁,而系统总线和系统内存中的实际区域内不会被锁定。同时,这条总线上的其它Pentium4、Intel Xeon或者P6系列处理器就回写所有已修改的数据并使它们的高速缓存失效,以保证系统内存的一致性。如果内存访问没有高速缓存且/或它跨越了高速缓存行的边界,那么这个处理器就会产生LOCK#信号,并在锁定操作期间不会响应总线控制请求
32位IA-32处理器支持对系统内存中的某个区域进行加锁的原子操作。这些操作常用来管理共享的数据结构(如信号量、段描述符、系统段或页表),两个或多个处理器可能同时会修改这些数据结构中的同一数据域或标志。处理器使用三个相互依赖的机制来实现加锁的原子操作: 1、保证原子操作 2、总线加锁,使用LOCK#信号和LOCK指令前缀 3、高速缓存相干性协议,确保对高速缓存中的数据结构执行原子操作(高速缓存锁)。这种机制存在于Pentium4、Intel Xeon和P6系列处理器中
IA-32处理器提供有一个LOCK#信号,会在某些关键内存操作期间被自动激活,去锁定系统总线。当这个输出信号发出的时候,来自其他处理器或总线代理的控制请求将被阻塞。软件能够通过预先在指令前添加LOCK前缀来指定需要LOCK语义的其它场合。 在Intel386、Intel486、Pentium处理器中,明确地对指令加锁会导致LOCK#信号的产生。由硬件设计人员来保证系统硬件中LOCK#信号的可用性,以控制处理器间的内存访问。 对于Pentinum4、Intel Xeon以及P6系列处理器,如果被访问的内存区域是在处理器内部进行高速缓存的,那么通常不发出LOCK#信号;相反,加锁只应用于处理器的高速缓存。
为显式地强制执行LOCK语义,软件可以在下列指令修改内存区域时使用LOCK前缀。当LOCK前缀被置于其它指令之前或者指令没有对内存进行写操作(也就是说目标操作数在寄存器中)时,会产生一个非法操作码异常(#UD)。 【1】位测试和修改指令(BTS、BTR、BTC) 【2】交换指令(XADD、CMPXCHG、CMPXCHG8B) 【3】自动假设有LOCK前缀的XCHG指令 【4】下列单操作数的算数和逻辑指令:INC、DEC、NOT、NEG 【5】下列双操作数的算数和逻辑指令:ADD、ADC、SUB、SBB、AND、OR、XOR 一个加锁的指令会保证对目标操作数所在的内存区域加锁,但是系统可能会将锁定区域解释得稍大一些。 软件应该使用相同的地址和操作数长度来访问信号量(用作处理器之间发送信号的共享内存)。例如,如果一个处理器使用一个字来访问信号量,其它处理器就不应该使用一个字节来访问这个信号量。 总线锁的完整性不收内存区域对齐的影响。加锁语义会一直持续,以满足更新整个操作数所需的总线周期个数。但是,建议加锁访问应该对齐在它们的自然边界上,以提升系统性能: 【1】任何8位访问的边界(加锁或不加锁) 【2】锁定的字访问的16位边界 【3】锁定的双字访问的32位边界 【4】锁定的四字访问的64位边界 对所有其它的内存操作和所有可见的外部事件来说,加锁的操作都是原子的。所有取指令和页表操作能够越过加锁的指令。加锁的指令可用于同步一个处理器写数据而另一个处理器读数据的操作。
IA-32架构提供了几种机制用来强化或弱化内存排序模型,以处理特殊的编程情形。这些机制包括: 【1】I/O指令、加锁指令、LOCK前缀以及串行化指令等,强制在处理器上进行较强的排序 【2】SFENCE指令(在Pentium III中引入)和LFENCE指令、MFENCE指令(在Pentium4和Intel Xeon处理器中引入)提供了某些特殊类型内存操作的排序和串行化功能 ...(这里还有两条就不写了) 这些机制可以通过下面的方式使用。 总线上的内存映射设备和其它I/O设备通常对向它们缓冲区写操作的顺序很敏感,I/O指令(IN指令和OUT指令)以下面的方式对这种访问执行强写操作的排序。在执行了一条I/O指令之前,处理器等待之前的所有指令执行完毕以及所有的缓冲区都被都被写入了内存。只有取指令和页表查询能够越过I/O指令,后续指令要等到I/O指令执行完毕才开始执行。
反复思考IA-32手册对lock指令作用的这几段描述,可以得出lock指令的几个作用:
- 锁总线,其它CPU对内存的读写请求都会被阻塞,直到锁释放,不过实际后来的处理器都采用锁缓存替代锁总线,因为锁总线的开销比较大,锁总线期间其他CPU没法访问内存
- lock后的写操作会回写已修改的数据,同时让其它CPU相关缓存行失效,从而重新从主存中加载最新的数据
- 不是内存屏障却能完成类似内存屏障的功能,阻止屏障两遍的指令重排序
(1)中写了由于效率问题,实际后来的处理器都采用锁缓存来替代锁总线,这种场景下多缓存的数据一致是通过缓存一致性协议来保证的,我们来看一下什么是缓存一致性协议。
缓存一致性协议
讲缓存一致性之前,先说一下缓存行的概念:
- 缓存是分段(line)的,一个段对应一块存储空间,我们称之为缓存行,它是CPU缓存中可分配的最小存储单元,大小32字节、64字节、128字节不等,这与CPU架构有关,通常来说是64字节。当CPU看到一条读取内存的指令时,它会把内存地址传递给一级数据缓存,一级数据缓存会检查它是否有这个内存地址对应的缓存段,如果没有就把整个缓存段从内存(或更高一级的缓存)中加载进来。注意,这里说的是一次加载整个缓存段,这就是上面提过的局部性原理
上面说了,LOCK#会锁总线,实际上这不现实,因为锁总线效率太低了。因此最好能做到:使用多组缓存,但是它们的行为看起来只有一组缓存那样。缓存一致性协议就是为了做到这一点而设计的,就像名称所暗示的那样,这类协议就是要使多组缓存的内容保持一致。
缓存一致性协议有多种,但是日常处理的大多数计算机设备都属于"嗅探(snooping)"协议,它的基本思想是:
所有内存的传输都发生在一条共享的总线上,而所有的处理器都能看到这条总线:缓存本身是独立的,但是内存是共享资源,所有的内存访问都要经过仲裁(同一个指令周期中,只有一个CPU缓存可以读写内存)。 CPU缓存不仅仅在做内存传输的时候才与总线打交道,而是不停在嗅探总线上发生的数据交换,跟踪其他缓存在做什么。所以当一个缓存代表它所属的处理器去读写内存时,其它处理器都会得到通知,它们以此来使自己的缓存保持同步。只要某个处理器一写内存,其它处理器马上知道这块内存在它们的缓存段中已失效。
MESI协议是当前最主流的缓存一致性协议,在MESI协议中,每个缓存行有4个状态,可用2个bit表示,它们分别是:
这里的I、S和M状态已经有了对应的概念:失效/未载入、干净以及脏的缓存段。所以这里新的知识点只有E状态,代表独占式访问,这个状态解决了"在我们开始修改某块内存之前,我们需要告诉其它处理器"这一问题:只有当缓存行处于E或者M状态时,处理器才能去写它,也就是说只有在这两种状态下,处理器是独占这个缓存行的。当处理器想写某个缓存行时,如果它没有独占权,它必须先发送一条"我要独占权"的请求给总线,这会通知其它处理器把它们拥有的同一缓存段的拷贝失效(如果有)。只有在获得独占权后,处理器才能开始修改数据----并且此时这个处理器知道,这个缓存行只有一份拷贝,在我自己的缓存里,所以不会有任何冲突。
反之,如果有其它处理器想读取这个缓存行(马上能知道,因为一直在嗅探总线),独占或已修改的缓存行必须先回到"共享"状态。如果是已修改的缓存行,那么还要先把内容回写到内存中。
由lock指令回看volatile变量读写
相信有了上面对于lock的解释,volatile关键字的实现原理应该是一目了然了。首先看一张图:
工作内存Work Memory其实就是对CPU寄存器和高速缓存的抽象,或者说每个线程的工作内存也可以简单理解为CPU寄存器和高速缓存。
那么当写两条线程Thread-A与Threab-B同时操作主存中的一个volatile变量i时,Thread-A写了变量i,那么:
- Thread-A发出LOCK#指令
- 发出的LOCK#指令锁总线(或锁缓存行),同时让Thread-B高速缓存中的缓存行内容失效
- Thread-A向主存回写最新修改的i
Thread-B读取变量i,那么:
- Thread-B发现对应地址的缓存行被锁了,等待锁的释放,缓存一致性协议会保证它读取到最新的值
由此可以看出,volatile关键字的读和普通变量的读取相比基本没差别,差别主要还是在变量的写操作上。
总结
之前对于volatile关键字的作用我个人还有一些会混淆的误区,在深入理解volatile关键字的作用之后,感觉对volatile的理解深了许多。相信看到文章这里的你,只要肯想、肯研究,一定会和我一样有恍然大悟、茅塞顿开的感觉。