线程安全
线程安全的重要性不言而喻,两个并发的线程如果有一个共享数据,如果没有采用任何的安全措施,那这个数据几乎一定会被破坏,这里看个例子。
public class App {
public static void main(String[] args) throws Exception {
count c = new count();
Thread manager = new Thread(new manager(c));
Thread manager2 = new Thread(new manager(c));
manager.start();
manager2.start();
manager.join();
manager2.join();
System.out.println(count.i);
}
}
class count {
static int i = 0;
public void increase() {++i;}
}
class manager implements Runnable {
count temp;
manager(count c) {
temp = c;
}
@Override
public void run() {
for(int i = 0 ; i < 100; ++ i) {
temp.increase();
}
}
}
上面这个代码,两个经理负责对一个 count类的 实例 i 进行加法,最后输出结果看似是 200 但其实总是低于200,而且每次运行的结果都有变化,为什么呢?
这是因为对共享数据的操作不是原子性的,什么是原子性?一个操作在执行过程中不会被中断。 ++ i 看似好像一步完成,但熟悉CPU指令执行结果的人都知道
这其实是 i = i + 1,先读取i的值,然后对 i 进行加法,再赋值。
假设两个线程同时读取,同时计算,先后写入结果,那么前一个结果就会被后一个结果覆盖,而前一个线程毫无所知,就会丢失一些值。
synchronized关键字
synchronized 保证同一时间只能有一个线程进入包裹的代码块,看代码。
@Override
public void run() {
for(int i = 0 ; i < 100; ++ i) {
synchronized(temp) { temp.increase(); }
}
}
这里指定了synchronized 加锁对象,当一个线程要进入代码块时,要获得给定对象的锁,如果其他线程有这个锁,则该线程就会等待直到其他线程释放这把锁。
这样保证一次只有一个线程执行 ++i 操作。
synchronized其实是把一个不是原子性的操作变为原子性了,在线程结束执行之前都不会有其他线程来干扰操作。
JMM内存模型
让我们从内存的角度来理解线程并发的安全问题,首先介绍一下JMM内存模型,建议读者先去其他博客看看Java内存区域的概念再回来看。
为什么有JMM内存模型
首先不存在什么JMM,堆栈。硬件上只有内存,CPU多级高速缓存,寄存器。这些缓存提升了CPU的执行效率,却也引入了内存一致性问题。
数据同时存在与缓存和主内存中,如果只是单线程的话没什么问题,但多线程同时访问数据的话就会出现上文例子的灾难性的后果。
Java内存区域(不是模型!) 是用虚拟机用来管理运行时内存的,并不能解决上述问题。所以Java语言在符合Java内存区域的基础上,推出了Java内存模型(JMM),
来解决缓冲内存数据不一致,处理器对代码乱序执行,编译器对代码指令重排等问题。
JMM的主内存和工作内存
JMM规定除方法参数,本地方法外的变量都保存在主内存,由所有线程共享。但线程对变量的操作都必须在工作内存中进行,首先把主内存的变量复制到工作内存中,操作完毕后再刷新到主内存中。
工作内存是线程的私有区域,不同线程不能访问其他线程的工作内存。
原子性,可见性,有序性
上面提到的是JMM对不同线程对共享变量访问过程的抽象。可以看到还有多级缓存的影子,正因为硬件的多级缓存才导致JMM也必须抽象出容易导致并发安全问题的主内存与工作内存之分,现在看看JMM是如何通过原子性,可见性,有序性,基于这个抽象解决问题的。
原子性:原子性指一个操作一开始就不可以中断。回看上面那个线程安全的例子,把CPU的寄存器换成JMM的工作内存抽象,当一个操作数被读入工作内存修改,其他线程正好写回主内存,这时该线程把修改完毕的数据写回主内存,就会导致问题。JMM使用synchronized关键字或重入锁来解决(如果还未学习Java并发编程锁的概念,请停止阅读,去学习一篇关于锁的优质博客)。
有人可能会有疑问:synchronized关键字不是我们自己编程的时候使用的吗,怎么和JMM内存模型扯上关系了?这是个好问题,我的理解是 JMM内存模型关键就是从CPU多级缓存中抽象出了主内存和工作内存,因为不同操作系统,硬件这些缓存都不同,如果没有JMM的抽象,那并发程序只能在一个平台上运行,换一个平台就会出现问题。正是因为这个普适的抽象,我们才能用锁来管理工作内存和主内存的变量访问,所以锁也算JMM内存模型的一部分。(仅个人理解,如果错误请在评论指正).
可见性和有序性:可见性是指一个线程修改过共享变量的值后,其他线程能否马上知道这个值,有序性是指多线程环境中代码语义与原来不一致。如果没有达到可见性的概念,其他线程可能读取到的是一个过期的值。就像上面的例子,从而导致结果错误。而指令重排机制可能导致失去有序性。指令重排是指编译器或处理器因为各种原因,例如优化,提前了一些指令的执行,在单线程中没有任何问题,即使重排也会保证单线程的逻辑正确,但是在多线程中语义就会出现改变。
如何保证可见性和有序性呢,通过上文我们可以看到,指令重排在单线程中不会有任何问题。所以我们可以使用synchronized关键字或volatile关键字。前者通过排他锁保证同一时间内,被修饰的代码是单线程执行的,保证了同一段代码中的变量对其他所有线程的可见性。而volatile关键字可以强行同步不同线程的共享变量,还防止局部指令重排,从而防止了不同代码段指令重排后语义改变。
volatile关键字
使用volatile关键字可以保证被修饰变量的可见性和有序性,但不保证原子性。
volatile保证可见性的原理是,volatile能够强行刷新更新后的数据到主内存中,同时通知其他线程的缓存数据已经过期。如果其他线程之前把volatile关键字加修饰的变量载进工作内存,就会重新从主内存加载最新的数据值。
volatile关键字保证有序性的原理是,volatile关键字能禁止指令重排,保证代码会严格按先后顺序执行。
但volatile关键字不能保证原子性,所以不能用它代替synchronized关键字,不然某些情况会有问题。比如 ++ i 操作,假设AB两个线程都读取 i 值到自己的缓存中,并进行加法运算,注意假设A线程先进行加法,可能不会立刻更新到主内存中,因为更新操作实际上是跟在加法指令后的一条指令完成的。如果在加法指令完成后切换到B线程执行,那就不会立刻刷新到主内存中。等A线程写入后B缓存失效,B缓存失效后重新读取,但是B线程加法指令已经执行过了,所以这时执行的指令为最后一步的写入操作,那这样就丢失了B线程的计算结果。所以结果出错,不能保证原子性。
其他Java并发程序基础
介绍完基础的synchronied,volatile 关键字和JMM内存模型,现在来看看其他并发程序基础。
线程组
如果线程的数量很多且分工明确,可以把相同功能的线程归类到一个线程组中管理。
public class App {
public static void main(String[] args) throws Exception {
ThreadGroup tg = new ThreadGroup("aGroup");
Thread t = new Thread(tg, new manager(),"aThread");
t.start();
}
}
class manager implements Runnable {
@Override
public void run() {
System.out.println(Thread.currentThread().getThreadGroup());
System.out.println(Thread.currentThread().getName());
}
}
上述代码中,初始化了一个线程组,并把它作为初始化线程的参数传入。线程构造函数的第一个参数是所属线程组对象,第二个参数是Runnable对象,第三个参数是线程的名称。
有人可能有疑惑,线程的名称不是变量的名称吗?其实不是,如果你没有给第三个参数,默认线程名称为 Thread-0,Thread-1 等,在查看信息的时候十分不方便,所以最好给每个线程都指定一个名称。
守护线程
守护线程是一种特殊的线程,为其他线程提供服务,像垃圾回收线程等。我们平常用的线程是工作线程,如果工作线程都退出了只剩下守护线程,那Java虚拟机就会自然退出。
Thread t = new Thread(new manager() );
t.setDaemon(true);
t.start();
上述代码用 setDaemon 方法设置一个线程为守护线程,注意设置必须要在start 方法前,不然会报错。
到这里Java并发程序基础基本上就结束了,我们在下一章介绍一下JDK并发包。