线程同步
首先,我们先来看一个Java多线程的实例,模拟银行叫号系统,代码如下:
//模拟银行叫号系统
class Number extends Thread {
private static int index = 1;//号码
//由于static变量的生命周期很长,同时共享资源或许会很多
//所以使用static修饰的共享资源对系统资源造成浪费
private String name; //柜台名称
private static final int MAX = 50;//最多叫到50号
public Number(String name) {
this.name = name;
}
@Override
public void run() {
while (index <= MAX) {
System.out.println("柜台:" + name + ", 当前号码为:" + index++);
}
}
}
//将线程的控制和业务逻辑分离开来
class NumberByRunnable implements Runnable {
private volatile int index = 1;//号码 共享资源并不是修改了之后对于其他线程立即可见 共享资源没有进行互斥
private static final int MAX = 50;//最多叫到50号
@Override
public void run() {
while (index <= MAX) {
//线程A 切换cpu时间片到B 1 sout1 index=2 切换cpu时间片到A 直接sout index->1
System.out.println("柜台:" + Thread.currentThread().getName() + ", 当前号码为:" + index++);
try {
TimeUnit.MILLISECONDS.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
public class BankCallSystem {
public static void main(String[] args) {
NumberByRunnable task = new NumberByRunnable();
Thread t1 = new Thread(task, "1号");
Thread t2 = new Thread(task, "2号");
Thread t3 = new Thread(task, "3号");
Thread t4 = new Thread(task, "4号");
Thread t5 = new Thread(task, "5号");
t1.start();
t2.start();
t3.start();
t4.start();
t5.start();
// Number n1 = new Number("1号");
// n1.start();
//
// Number n2 = new Number("2号");
// n2.start();
//
// Number n3 = new Number("3号");
// n3.start();
//
// Number n4 = new Number("4号");
// n4.start();
}
}
但是,通过运行我们会发现,这个代码是有问题的!!!
- 1、某个号码会有重复。
- 2、某个号码会被略过。
- 3、号码有可能超过当前最大值。
而出现这些问题的主要原因就是:多个线程同时操作共享资源,出现了数据不一致的问题。因此在这种情况下,就需要线程同步。
为什么需要线程同步?
共享资源会有多个线程同步操作的时候,并且我们没有进行任何的同步操作,就会发生冲突,因为我们不清楚每一个线程什么时候开始执行什么时候执行结束,也就无法控制当前线程的最终结果。
实现线程同步的最终目的就是实现并发编程,我们如果想实现一个高并发的项目,就必须解决并发编程所带来的问题,例如运行结果不正确,发生死锁、死循环等问题,因此我们实现线程同步并使其满足并发编程的三大特性,从而避免这些问题的发生。
并发编程的三大特性
原子性
原子操作是不可分割的操作,一个原子操作是不会被其他线程打断的,所以不需要同步一个原子操作。
例如:int i = 10
就是一个原子操作,此时它不需要进行线程同步。
而i++
可分解为i -> i + 1 -> i = i + 1
即读取 -> 修改 -> 写入
操作,此时它不是一个原子操作,需要进行线程同步。
多个原子操作合起来则不是一个原子操作,这时就需要进行同步
可见性
当一个线程对共享变量进行了修改,那么另外可以立即看到修改后的最新值。
假如有几个线程同时需要访问主内存中的共享变量,此时每一个线程都会拥有一个私有的工作内存,里面会有这个共享变量的副本,如果这个线程对该变量的访问结束会将修改后的结果刷新到主内存中,而如果该线程被打断没有完成对变量的访问,那么其他线程将不会看到修改一半的结果,而可见性就是保证其他线程可以立即看到修改后的最新结果。我们可以使用
volatile
关键字实现变量的可见性。
例如:
public class TestDemo {
private volatile static int initValue = 0; //1
private final static int MAX = 5; //2
public static void main(String[] args) {
new Thread("reader") {
//initValue副本
@Override
public void run() {
int localValue = initValue;
while (localValue < MAX) {
if (initValue != localValue) {
System.out.println("The initValue is updated to " + initValue);
localValue = initValue;
}
}
}
}.start();
new Thread("updater") {
@Override
public void run() {
//initValue副本
int localValue = initValue;
while (localValue < MAX) {
System.out.println("The initValue is changed " + (++localValue));
initValue = localValue;
//短暂休眠,目的为了让reader线程立即获取最新的initValue,输出
try {
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}.start();
}
}
当我们initValue
变量没有加上volatile
关键字时,两个线程之间的initValue
变量不可见,原因就是它们只在自己的工作内存中可见,而主内存不可见,加上volatile
关键字后,两个线程均可见。
- 执行结果:
The initValue is changed 1
The initValue is updated to 1
The initValue is changed 2
The initValue is updated to 2
The initValue is changed 3
The initValue is updated to 3
The initValue is changed 4
The initValue is updated to 4
The initValue is changed 5
The initValue is updated to 5
有序性
程序代码在执行过程中的先后顺序。
Java编译器会在运行期优化代码的执行顺序,导致了代码的执行顺序未必就是开发者编写代码时的顺序。
而在多线程环境下,如果一段程序在执行过程中丢失CPU资源,那么有可能未执行的代码片段可能受其他线程的影响,导致程序代码出错,这种问题也可以通过volatile
关键字进行避免。
线程同步常见概念
- 临界资源:同一时刻只允许一个线程访问的资源。
- 临界区:访问临界资源的代码段。
- 临界区特点:某一时刻如果有一个线程正在访问代码段,其他线程想要访问,只有等待当前的线程离开该代码段,你才可以访问,这样保证了线程安全。
保证线程同步的关键字
synchronized关键字
为了保证我们的线程安全,引入了Synchronized关键字,其目的是防止线程干扰和内存一致性错误。Synchronized关键字中放入代码段或者定义成方法。此时这个方法就定义为同步方法。
- synchronized关键字了保证并发编程的原子性、有序性、可见性。
- tips:单独对某一行代码定义为同步代码没有任何意义。
使用方式
- 1、同步方法。
public synchronized void sync(){
//表示要访问这个成员方法必须获取当前方法所在类的this引用的锁
}
- 2、同步代码块。
public final Object obj = new Object();
public void sync(){
synchronized(obj){
//需要保证独占性的资源
}
}
- 练习:实现两个线程,线程A输出5,4,3,2,1之后线程B再次输出5,4,3,2,1。
class MyThread implements Runnable {
public synchronized void test1() {
//获取MyThread类当前this引用的对象锁
int i = 5;
while (i >= 1) {
System.out.println(Thread.currentThread().getName() + "::" + i--);
try {
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
public void test2() {
synchronized (Mythread.class) {
//获取当前Mythread类中class对象的对象锁
int i = 5;
while (i >= 1) {
System.out.println(Thread.currentThread().getName() + "::" + i--);
try {
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
@Override
public void run() {
//test1();
test2();
}
}
public static void main(String[] args) {
Runnable runnable = new MyThread();
Thread threadA = new Thread(runnable, "threadA");
Thread threadB = new Thread(runnable, "threadB");
threadA.start();
threadB.start();
}
//执行结果:
threadA::5
threadA::4
threadA::3
threadA::2
threadA::1
threadB::5
threadB::4
threadB::3
threadB::2
threadB::1
底层如何实现锁的获取
那么Java的底层是如何获取到锁的呢?获取到什么锁?
首先我们要理解一个获得锁这个概念,不是获取代码段,或者方法的锁,而是获取对象的锁。
所有的对象天生就有一把锁,而这把锁叫做monitor锁,也就是监视器锁。
每个对象有一个监视器锁(monitor)。当monitor被占用时就会处于锁定状态,线程执行monitorenter指令时尝试获取monitor的所有权,过程如下:
- 1、如果monitor的进入数为0,则该线程进入monitor,然后将进入数设置为1,该线程即为monitor的所有者。
- 2、如果线程已经占有该monitor,只是重新进入,则进入monitor的进入数加1。
- 3、如果其他线程已经占用了monitor,则该线程进入阻塞状态,直到monitor的进入数为0,再重新尝试获取monitor的所有权。
执行monitorexit的线程必须是object ref所对应的monitor的所有者。指令执行时,monitor的进入数减1,如果减1后进入数为0,那线程退出monitor,不再是这个monitor的所有者。其他被这个monitor阻塞的线程可以尝试去获取这个 monitor 的所有权。
从反编译的结果来看,方法的同步并没有通过指令 monitorenter 和monitorexit 来完成(理论上其实也可以通过这两条指令来实现),不过同步方法相对于普通方法,其常量池中多了ACC_SYNCHRONIZED标示符。JVM就是根据该标示符来实现方法的同步的:当方法调用时,调用指令将会检查方法的ACC_SYNCHRONIZED 访问标志是否被设置,如果设置了,执行线程将先获取monitor,获取成功之后才能执行方法体,方法执行完后再释放monitor。在方法执行期间,其他任何线程都无法再获得同一个monitor对象。 其实本质上没有区别,只是方法的同步是一种隐式的方式来实现,无需通过字节码来完成。
Java对象头中的markword以及锁升级
首先我们了解下对象头的结构:
- Java对象头的结构分别是:markword,类型指针。
- 一个数组的对象的对象头是:markword,类型指针,数组长度。
JVM中的markword图如下:
我们能看到锁并不是一次就让其他线程阻塞的,因为让其他线程阻塞是需要OS(操作系统的调度),如果线程太多,那么系统的调度就会很慢了,这与我们想要的快速不符。所以我们在中间进行过度。
那么我们就来了解一下Synchronized锁的升级吧。
-
概述:
- 无锁:现在这个对象没有任何线程占用它,所以不需要上锁,先来先得。
- 偏向锁:此时线程A要获取锁,一看没有人占用这把锁,那就拿来用,此时这把锁被A标记,这就是我A的锁,锁升级为偏向锁,偏向A。下次A再获得锁的时候,直接拿就行了,锁已经被A标记了,此时线程B也想要锁,但是现在锁被A标记了,那不行B就抢(通过CAS的方式),抢成功了。那么锁就被B标记,此时还是偏向锁,但是偏向了B,下次B来的时候就不用抢了,这就B的了;抢失败了,现在锁发现有人再抢自己,锁烦死了,直接升级为轻量级锁。
- 轻量级锁:B抢失败之后,B就想那不行,不能放弃,B再抢(此时锁变成为自旋锁),抢了10次!此时发现B还抢这把锁,锁开始恼怒了,你不能再抢我了,你给我原地站着。此时轻量级锁升级为重量级锁,由OS调度。
- 重量级锁:此时线程A获取到锁,其他线程,例如线程B,直接阻塞。
-
详细:
- 无锁:当这个对象Object的对象头中markword的锁标志位位01,并且偏向锁的标记为0,那就证明此时的锁状态就是无锁。此时线程先来先得,谁先拿到Object的锁,这个锁就升级为偏向锁,并且偏向它,偏向的线程用线程ID号来保存。
- 偏向锁:现在线程A首先抢到了Object的锁使用权,此时锁的偏向锁的状态为置为1,线程ID保存的就是线程A的ID。A线程要执行这个锁关联的任何代码,不需要再做任何检查和切换,这种竞争不激烈的情况下,效率非常高。此时线程B的也要抢锁的使用权,就使用CAS尝试抢夺锁。抢夺成功,那就直接把线程ID改为自己的(B的)线程ID;抢夺失败,此时锁发现自己在被其他线程争抢,升级为轻量级锁。
- 轻量级锁:锁标志位置为00.当锁升级为轻量级锁的时候,线程B还在不断的抢夺(抢夺的方法还是CAS)。那么锁就变成自旋锁,(JDK1.6版本是B再抢夺10次),当自旋次数内A释放了该锁的资源,那么此时B就进去占用,如果此时B没结束,线程A又来了,接着进入自旋状态抢夺锁,一直到自旋锁结束,或者B释放锁的资源。此时自选锁结束了,但是还是没抢到锁的资源。那就升级为重量级锁,让老大哥OS去操作。
- 重量级锁:锁标志位置为10。此时线程A和线程B还有线程C要获取锁的对象,由OS去调度,A获取了,那么B和C就阻塞,等待A的释放,此时A释放了,再由OS调度,B和C谁来获得锁,其他线程都阻塞。
synchronized可重入锁
可重入锁:同一个线程重复请求自己持有的锁对象,可以请求成功而不会发生死锁。
volatile关键字
volatile关键字虽然从字面上理解起来比较简单,但是要用好不是一件容易的事情。由于volatile关键字是与Java的内存模型有关的,因此在讲述volatile关键之前,我们先来了解一下与内存模型相关的概念和知识,然后分析了volatile关键字的实现原理,最后给出了几个使用volatile关键字的场景。
内存模型的相关概念
大家都知道,计算机在执行程序时,每条指令都是在CPU中执行的,而执行指令过程中,势必涉及到数据的读取和写入。由于程序运行过程中的临时数据是存放在主存(物理内存)当中的,这时就存在一个问题,由于CPU执行速度很快,而从内存读取数据和向内存写入数据的过程跟CPU执行指令的速度比起来要慢的多,因此如果任何时候对数据的操作都要通过和内存的交互来进行,会大大降低指令执行的速度。因此在CPU里面就有了高速缓存。
也就是,当程序在运行过程中,会将运算需要的数据从主存复制一份到CPU的高速缓存当中,那么CPU进行计算时就可以直接从它的高速缓存读取数据和向其中写入数据,当运算结束之后,再将高速缓存中的数据刷新到主存当中。举个简单的例子,比如下面的这段代码:
i = i + 1;
当线程执行这个语句时,会先从主存当中读取i的值,然后复制一份到高速缓存当中,然后CPU执行指令对i进行加1操作,然后将数据写入高速缓存,最后将高速缓存中i最新的值刷新到主存当中。
这个代码在单线程中运行是没有任何问题的,但是在多线程中运行就会有问题了。在多核CPU中,每条线程可能运行于不同的CPU中,因此每个线程运行时有自己的高速缓存(对单核CPU来说,其实也会出现这种问题,只不过是以线程调度的形式来分别执行的)。本文我们以多核CPU为例。
比如同时有2个线程执行这段代码,假如初始时i的值为0,那么我们希望两个线程执行完之后i的值变为2。但是事实会是这样吗?
可能存在下面一种情况:初始时,两个线程分别读取i的值存入各自所在的CPU的高速缓存当中,然后线程1进行加1操作,然后把i的最新值1写入到内存。此时线程2的高速缓存当中i的值还是0,进行加1操作之后,i的值为1,然后线程2把i的值写入内存。
最终结果i的值是1,而不是2。这就是著名的缓存一致性问题。通常称这种被多个线程访问的变量为共享变量。
也就是说,如果一个变量在多个CPU中都存在缓存(一般在多线程编程时才会出现),那么就可能存在缓存不一致的问题。
为了解决缓存不一致性问题,通常来说有以下2种解决方法:
- 1、通过在总线加LOCK锁的方式
- 2、通过缓存一致性协议。
这2种方式都是硬件层面上提供的方式。
在早期的CPU当中,是通过在总线上加LOCK锁的形式来解决缓存不一致的问题。因为CPU和其他部件进行通信都是通过总线来进行的,如果对总线加LOCK锁的话,也就是说阻塞了其他CPU对其他部件访问(如内存),从而使得只能有一个CPU能使用这个变量的内存。比如上面例子中 如果一个线程在执行 i = i +1,如果在执行这段代码的过程中,在总线上发出了LCOK#锁的信号,那么只有等待这段代码完全执行完毕之后,其他CPU才能从变量i所在的内存读取变量,然后进行相应的操作。这样就解决了缓存不一致的问题。
但是上面的方式会有一个问题,由于在锁住总线期间,其他CPU无法访问内存,导致效率低下。
所以就出现了缓存一致性协议。最出名的就是Intel 的MESI协议,MESI协议保证了每个缓存中使用的共享变量的副本是一致的。它核心的思想是:当CPU写数据时,如果发现操作的变量是共享变量,即在其他CPU中也存在该变量的副本,会发出信号通知其他CPU将该变量的缓存行置为无效状态,因此当其他CPU需要读取这个变量时,发现自己缓存中缓存该变量的缓存行是无效的,那么它就会从内存重新读取。
相关链接:
volatile关键字的作用
- 1、保证可见性。(与Java内存模型相关)
- 2、保证有序性。
volatile关键字修饰变量,修饰的变量会存在一个lock;的前缀,这个前缀相当于是一个内存屏障。
这个内存屏障可以提供:
- a、确保指令重排序时不会将后面的代码排到内存屏障之前。
- b、确保指令重排序时不会将前面的代码排到内存屏障之后。
- c、确保在执行到内存屏障修饰的指令时前面的代码已经全部执行完成。
- d、强制地将线程工作内存中的修改刷新到主内存当中。
- e、如果是写操作,则会导致其他线程工作内存当中的缓存数据失效。
例如:
int x = 10;
int y = x;
volatile int z = 100; //内存屏障
int sum = y + z;
在上述代码中,volatile int z = 100;
一行代码相当于此处的内存屏障,因此根据上述规则,可以保障int sum = y + z;
不会在内存屏障之前执行,int x = 10;
和int y = x;
不会在内存屏障之后执行并且保障其在执行到内存屏障之前的代码全部执行完成。
而对于d、e
两个规则,假如此时有两个线程threadA
和threadB
,两个线程均需要对z
变量进行访问,此时z
变量的值100就会被刷新到主内存当中供两个线程使用,而如果threadA
先执行并且对z
变量的值进行了修改,那么threadB
中的缓存数据值100将会失效,并从主内存中获取最新的数据。
volatile关键字的使用
例:创建10个线程对index++1000次,获取10个线程加加之后的结果。
public class TestDemo {
public volatile static int index = 0;
private static CountDownLatch countDownLatch = new CountDownLatch(10); //创建一个给定当前等待线程数目的countDownLatch对象
public static void increase() {
index++; //非原子操作
}
public static void main(String[] args) {
for (int i = 0; i < 10; i++) {
new Thread() {
@Override
public void run() {
for (int i = 0; i < 1000; i++) {
increase();
}
//将CountDownLatch中的计数器-1
countDownLatch.countDown();
}
}.start();
}
try {
//调用await使得当前线程阻塞,直到计数器减为0
countDownLatch.await();
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(index);
}
}
注意:volatile关键字不具备保证原子性的语义,只能够禁止指令重排序,保证共享变量修改立即刷新至主内存。