怎样理解线程

Jul 16, 2016


线程

讲线程之前,我先讲操作系统中进程的概念;

什么是进程?

我们都知道程序,举个例子记事本编辑器是一个程序,但是单单以记事本编辑器来讲它只是一个可执行文件(如下图就是ubuntu下自带文本编辑器gedit的可执行文件);

gedit

在我们不运行时,是静态存放在磁盘上; 但是我们运行gedit时,会产生什么情况呢?如下图:

processs

执行程序后,进入gedit主界面,这时候我们查看系统进程(ps -x)会发现新产生一个进程

3082 ? 	Sl	0:00 gedit

所以这时候程序才真正的占用操作系统资源,是动态的

进程是运行中的程序,但是一个程序运行可能会产生多个进程

什么是线程?

线程是操作系统能够进行运算调度的最小单位,它被包含在进程之中,是进程的实际运作单位;

执行程序后,给予我一个直观的感受就是开启若干个进程,那我就会认为进程就是操作系统实际运算调度的单位;

因为操作系统实际运行调度的是线程,那我可以理解:

线程就是进程的单一的执行路径,一个进程中可以并发多个线程,每个线程执行不同的任务

thread

一个进程内线程共享进程内存空间;

举个例子(飞机票订票):

这样会产生几个问题(订同一趟飞机的飞机票):

  • 当飞机票容量为0时,而这时又有许多用户正在订票;

  • 当用户A在订票过程中,电脑崩溃,但是有其他用户正在查看余票,他们显示的余票容量却是用户A订票成功后的容量

  • 当用户A订票成功(若干张票),而此时有其他用户正在查看余票,他们显示的余票容量却是用户A订票之前的容量;

就是因为共享内存空间所以产生了线程安全问题(如何解决这个问题我们在下面线程安全中再提)

还有重要的一点,单核CPU在单一时间点只会执行一个线程;

一个进程被载入时,可能并行执行多个线程;

这里又要引入两个概念:并行与并发的关系

并发是指,程序在运行的过程中存在多于一个的执行上下文。这些执行上下文一般对应着不同的调用栈

  • 并行就是在同一个时间点执行多个任务,针对多核处理器

  • 并发就是在一个时间间隔内执行多个任务,针对单核处理器

所以一般java多线程并发,线程上下文切换是很消耗性能(尽量减少线程上下文切换)

参考Python多线程GIL

请参考进程与线程的一个简单解释


JVM线程内存模型

缓存一致性

首先从计算机来讲,一般来讲计算机都可以同时去做几件事情(比如我们在登录QQ的同时去打开Word去编写文档等),所以讲多任务处理是计算机必备的特性了;

我们都知道,计算机的发展是非常快的,计算机的运算能力越来越强

但是因为计算机的运算速度与磁盘和其他网络通信速度差距比较大,所以计算机的大部分时间都花在磁盘I/O读写,网络通信等待或者数据库的访问,所以这样会导致CPU会在大部分时间是处于等待其他资源的状态(所以让计算机同时处理多项任务可以有效的利用CPU资源)

计算机并发执行多任务与高效利用CPU资源的联系:

CPU需要与内存(磁盘)进行交互,进行读写操作,这个I/O操作是不可避免的,但是由于磁盘与CPU的运算速度有几个数量级的差距,所以现代计算机系统引入一层读写速度尽可能接近CPU运算速度的高速缓存(Cache)来作为内存与CPU之间的缓冲:

将运算需要使用的数据复制到缓存中,让运算能快速进行,当运算结束后再从缓存同步回内存之中(磁盘),这样处理器就无需等待缓慢的内存读写

这样产生一个问题:

在多CPU处理器系统中,每个CPU都有自己的高速缓存(Cache),但是都共享同一主内存(磁盘),当其中一个CPU进行运算操作,将运算结果写入内存,同一时刻有CPU从主内存读取数据,会导致CPU的缓存不一致;

所以操作系统引入一个”缓存一致性”问题,如下图:

cache

缓存一致性请参考博文为什么程序员需要关心顺序一致性(Sequential Consistency)而不是Cache一致性(Cache Coherence?)


Java内存模型

Java内存模型的主要目标是定义程序中各个变量的访问规则(虚拟机将变量写入内存和从内存中读取变量的底层细节);

ps:此处的变量指的是实例字段(属性),静态字段和构成数组对象的元素,不包括局部变量与方法参数,因为后者是线程私有的,不会被共享所以不存在竞争问题

如果局部变量是一个reference类型,它引用的对象在Java堆中可被各个线程共享,但是reference本身是存储在栈中局部变量表中,它是线程私有的

线程内存模型如下图:

threadmodel

Load和Write操作的实际都是变量;

Java内存模型规定变量存放在主内存中,每个线程都有一个自己的工作内存,线程当需要主内存中的某个变量时,需要从主内存read变量,再将变量load工作内存(实际上工作内存存放的是变量的拷贝)

ps: 拷贝,只是部分拷贝,比如有一个10M的对象,工作内存存放的可能只是对象中的某些字段等,而不会将整个对象直接拷贝到工作内存;

线程对变量的读写操作都必须在工作内存中进行,而不能直接读写主内存中的变量,不同线程之间也无法直接访问对方工作内存中的变量,线程间变量值的传递均需要通过主内存来完成

主内存是什么? 工作内存又是什么?

在Java内存区域中规定了栈,堆,方法区

在上面我们知道,主内存存放变量,而堆,方法区正是存放这些变量的实际区域,那我可以理解:

  • 主内存对应堆中的对象实例数据部分和方法区常量池中静态数据部分
  • 工作内存对应虚拟机栈中的部分区域

load和write操作

一个变量如何从主内存拷贝到工作内存,如何从工作内存同步到主内存?

Java内存模型中定义8种操作来完成:

  • read 从主内存中读取变量,把变量的值从主内存传输到线程的工作内存中
  • load 将read操作从主内存中得到的变量值放入工作内存的变量副本中

  • use 将工作内存中一个变量的值传递给执行引擎,当虚拟机遇到一个需要使用该变量的值的字节码指令时执行这个操作
  • assign 它把一个从执行引擎接收到的值赋给工作内存的变量,当虚拟机遇到一个给该变量赋值的字节码指令时执行这个操作

  • store 将工作内存中一个变量的值传送到主内存中
  • write 将store操作从工作内存中得到的变量的值放入主内存的变量中

  • lock 将一个变量标识为一条线程独占的状态
  • unlock 将一个处于锁定状态的变量释放出来

这8种操作都是原子的,不可再分的(对于64位double,long类型变量例外,暂不讨论)


实例分析线程本质及其安全

原子性与可见性

线程的本质—> 原子性与可见性

原子性

请参考:多线程程序中操作的原子性

原子性我认为就是操作原子性,即指的是这个操作是不可再分的;

从下面一个实例来进行分析:

A线程:
	run(){
		i = 1;
	}

B线程:
	
	run(){
		i++;
	}

分别i=1,i++,x=y来分析这三个操作是否是原子操作,是否是不可再分的操作


JVM指令请参考JVM详解

  1. i=1:

使用javap -verbose A 看看编译后的字节码文件

public void run();
	descriptor: ()V
	flags: ACC_PUBLIC
	Code:
		stack=2, locals=1, args_size=1
		    0: aload_0
		    1: iconst_1
		    2: putfield	//Field i:I
		    5: return
		LineNumberTable:
		    line 10:  0
		    line 11:  5

i=1 操作包括:aload_0,iconst_1,putfield 3条指令 实际i=1就等于iconst_1 JVM指令:意思就是在i赋值为1,并进行将int型(1)推送至栈顶,由于我将i声明为实例变量,所以有putfield操作; 那我们就可以得知i=1实际就只是赋值操作,实际上不管多少个线程同时执行i=1操作,i始终都是被赋予1值,不会出现i值被破坏的情况

所以i=1是原子操作,不需要进行同步处理;

  1. i++

使用javap -verbose B 查看编译后字节码文件

public void run();
	descriptor: ()V
	flags: ACC_PUBLIC
	Code:
		stack=3, locals=1, args_size=1
		    0: aload_0
		    1: dup
		    2: getfield	//Field i:I
		    5: iconst_1
		    6: iadd
		    7: putfield
		   10: return
		LineNumberTable:
		    line 10: 0
		    line 11: 10

dup指令参见dup指令

i++操作包括 aload_0,dup,getfield,iconst_1,iadd,putfield 6条指令 但是i++实际相当于:iconst_1,iadd 2条指令

iadd请参考iadd指令

指令iload_0将存储在局部变量中索引为0整数压入操作数栈中,其后iadd指令从操作数栈中弹出那个整数自增操作

所以这会造成一个问题:

比如现在有两个线程,都执行i++操作:

  • i初始为0,1个线程执行i++,i赋值成2
  • 另一个线程正好执行i++,i赋值成2
  • 而实际正确结果i赋值3

所以这样i的值遭到了破坏

i++不是原子操作,需要进行同步处理;

在Java中可以通过使用synchronized关键字进行同步处理

可见性

可见性可以理解为状态一致性,即在多个线程共享同一资源情况下,一个线程对资源进行修改操作,资源的状态得到更新,新的状态能够立即被其他线程所得知;

以i++为例:

public int i;

Thread A{
	run(){
	     while(true){
		i++;
		System.out.println("A thread i value: " + i);
		sleep(1000);
	     }
	}
}

Thread B{
	run(){
	     while(true){
		i++;
		System.out.println("B thread i value: " + i);
		sleep(1000);
	     }
	}
}

main(){
	new A().strat();;
	new B().start();
}

i的值是不可预计的,如下图所示:

visual

A执行i++操作,而此时B也正在执行i++操作,可以A对i修改后的i值并没有被B所见,所以导致A,B输出i的值是一样的;

所以解决这个问题的方式可以通过volatile,synchronized,final,声明锁对象(Lock lock = new ReentrantLock(),利用try-finally块来进行加锁解锁处理)

如果使用volatitle关键字声明i变量,只是表明i变量对所有线程是可见的,但是不能保证一定是安全的,因为volatitle关键字也存在不一致情况;

请参见[深入理解Java虚拟机:JVM高级特性与最佳实践第五部分–13章 线程安全与锁优化]

请仔细阅读深入理解Java虚拟机:JVM高级特性与最佳实践第五部分–12.3 java内存模型

请参见为什么Java程序中慎用volatile关键字

分析实例

以前文提到的飞机票订票为例,我提出3个问题分别是:

  • 1.当飞机票容量为0时,而这时又有许多用户正在订票;

  • 2.当用户A在订票过程中,电脑崩溃,但是有其他用户正在查看余票,他们显示的余票容量却是用户A订票成功后的容量

  • 3.当用户A订票成功(若干张票),而此时有其他用户正在查看余票,他们显示的余票容量却是用户A订票之前的容量;

主要问题就是:

飞机票容量的状态不一致问题

1问题,其实是一个边界问题,只需要在飞机票容量为0的情况下,做一个特殊处理,返回给所有正在订票的用户一个提示信息:票已订完,并更新航班状态显示为无票,关闭订票业务

2问题,我个人理解其实是一个原子操作问题,如果一个订票系统没有考虑这种情况的话,那么我估计这家公司应该关闭了;订票业务必须是一个原子操作,用户要么成功订票,要么订票失败;这样就是用户付款完成之前,订票都不能算成功,必须在确认用户付款之后,才能算做订票成功,才能更新飞机余票状态

3问题,就是一个可见性问题,类比i–操作,一个用户订票成功,这个时候余票状态进行更新,但是此时其他用户在查看余票,这时候余票的状态没有得到更新

总结

这篇博客花了蛮多时间,写的比较慢,不仅仅是因为需要去阅读相关资料(找资料的过程也是一个自我提高的过程),并且在写的时候需要想到很多(比如怎么样去表达才能够表达清楚),其实还有许多东西没有扩展(比如sychronized的原理,与ReentrantLock锁对象的区别,单例是否是线程安全等),所以先写到这,下次有时间再来更新线程安全问题;