基础
-
多线程的含义和目的
理解多线程就要先区分并发和并行这两个概念。
并行:多个CPU在同一时间段内真正的执行多个任务。
并发:CPU在同一时间段内只能执行一个任务,但是利用其本身的调度,不停的切换执行的线程,看起来是同一时间执行多个任务。
有图会比较好理解 -
并发带来的问题
多个线程访问同一块数据资源,那么这个数据资源就是处于临界区的资源,就此产生了竞争。
CPU的调度是不容易被人为的控制的,因此多个线程读写临界区的数据,就会产生数据被线程A更新后又被线程B改写,此时的线程A是不知道数据已经被改写了的,对于它来说,此时被改写后的数据就是错误的。这是线程安全问题。
为了解决这个问题就产生了锁,线程在操作临界区资源之前需要先获取这个资源的锁,获得锁之后,如果有其他线程访问该资源,则会被阻塞,直到当前线程释放了对象的锁,才能继续运行。
存在线程安全的例子有很多,这里就不写了。 -
创建线程
有3种方式创建一个线程
1.继承Thread类---->Thread t = new AThread()
实现run(),run()中的代码即为线程运行时执行的代码
2.实现Runnable接口---->Thread t = new Thread(new ARunnable())
实现run(),同上,run()中的代码即为线程运行时执行的代码。建议通过Runnable接口创建一个线程,因为该线程还有可能继承其他类
3.实现Callable接口---->配合FutureTask
Runnable接口和Thread类是没有返回值的,因此,我们无法判断线程的运行状态,也无法获得线程的运行结果,所以jdk 1.5提出了一个新的接口Callable -
线程的声明周期
线程有7种状态
New 新建
Runnable 就绪
Running 正在运行
Blocked 阻塞
Waiting 等待
Timed-waiting 限时等待
Terminated 终止
线程各状态之间的关系如下
线程对象thread由new关键字创建后,处于New(新建)状态,此时并未运行;当调用thread.start()时,线程进入Runnable(就绪)状态,等待获取CPU时间片;线程获取到CPU时间片,即CPU的运行权后,会进入Running(运行)状态,调用thread.run(),执行线程代码,当时间片结束时,线程回到Runnable状态,在线程执行结束前,都会在Running与Runnable间来回切换。Running状态下线程状态会有以下几种变化:
(1)代码正常运行结束,或在运行时抛出了异常并结束,此时thread进入Terminated(终止)状态,线程结束;
(2)线程需要获取某一对象的锁,但是该锁正在被其他线程占用,因此thread进入Blocked(阻塞)状态,等待获取对象锁,此时thread位于该对象的锁池中。当它获取到目标对象的锁之后则从Blocked回到Runnable态,等待获取时间片继续执行代码;
(3)线程调用了wait()/join(),则释放自己占用的对象A的锁,从Running进入Waiting(等待)状态,此时thread位于对象A的等待池中。当其他线程thread0获取到对象A的锁,并调用notify()/notifyAll(),唤醒了thread线程,此时thread会先进入Blocked状态,因为对象A的锁仍被thread0占用,当thread0运行结束后才会释放对象A的锁,然后thread获取到对象A的锁,回到Runnable状态,等待获取时间片继续执行代码;
线程调用了Thread.sleep(longtime)/wait(longtime),从Running态进入到Timed-waiting(限时等待)状态,两者的区别在于sleep不释放锁,wait释放锁,此时该线程位于对象的等待池中,若其他线程调用了notify()/nofityAll(),唤醒了thread线程,或休眠时间结束,此时线程会从Timed-waiting状态进入Blocked状态,等待获取对象锁,继续执行代码;
原理
- 内存模型
程序运行时,临时数据保存在内存中,一开始,CPU执行指令的时候都要去内存中读取数据,但是CPU执行指令的速度快,读取内存的速度慢,为了解决效率问题,提出了CPU高速缓存来保存内存中数据的副本。但是,当多核CPU处理指令时就发生了问题,每个CPU都有内存数据的副本,若当CPU1修改了共享数据并刷新到内存,而CPU2又重写了该共享数据,就会引起数据被破坏的问题,该问题被称为缓存一致性问题。 - 缓存一致性问题及解决
为了解决缓存一致性问题,提出了缓存一致性协议,当CPU写数据时,如果发现操作的变量是共享变量,即在其他CPU中也存在该变量的副本,会发出信号通知其他CPU将该变量的缓存行置为无效状态,因此其他CPU读取这个变量时,发现自己的缓存行是无效的,就会去内存中重新读取,这样就能确保,每个CPU读取的共享变量的值都是最新的。
此外,程序需要遵循3条规则:
(1)原子性:一个操作或多个操作,要么全部执行并执行的过程中不会被打断,要么就不执行;
(2)可见性:当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看到修改后的值;
有序性:在执行代码时CPU有可能进行指令重排,单线程的情况下不会出问题,但是在多线程的情况下可能会引起代码顺序的变化,因此要求程序保证指令运行的有序性;
Java中的多线程
-
原理
Java是如何解决缓存一致性问题的呢,Java内存模型规定所有的变量都是存在主存中,每个线程有自己的工作内存,线程对变量的操作只能在本地内存中进行,然后再写入主存中,且不可以访问其他线程的本地内存(也叫工作内存)。
同时,通过synchronized和lock保证非原子性操作的原子性;通过volatile、synchronized、lock保证可见性;对于有序性,虚拟机以happens-befored原则为基础,以volatile、synchronized、lock尽可能的保证指令的有序性。
虚拟机本身会对指令进行重排序,但会遵循以下原则。
– 程序次序规则:一个线程内,按照代码顺序,书写在前面的操作先行发生于书写在后面的操作,即对没有数据依赖的指令进行重排,但是多线程环境下无法保证线程正确性
– 锁定规则:一个unLock操作先行发生于后面对同一个锁的lock操作
– volatile变量规则:对一个变量的写操作先行发生于后面对这个变量的读操作
– 传递规则:如果操作A先行发生于操作B,而操作B又先行发生于操作C,则可以得出操作A先行发生于操作C
– 线程启动规则:Thread对象的start()方法先行发生于此线程的每个一个动作
– 线程中断规则:对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生
– 线程终结规则:线程中所有的操作都先行发生于线程的终止检测,我们可以通过Thread.join()方法结束、Thread.isAlive()的返回值手段检测到线程已经终止执行
– 对象终结规则:一个对象的初始化完成先行发生于他的finalize()方法的开始 -
实现
(1)线程安全问题
单线程中,程序调用类实例的方法修改类实例的成员,完成各种业务。在多线程中,若一个类实例会被多个线程同时修改,那么就称其为线程共享变量。例如如下代码,其中x为线程共享变量:
threadA
1.x=1
2.x++;
3.print(x);//应该为2
threadB
4.x=5
5.x++;
6.print(x);//应该为6
我们期待语句以1,2,3,4,5,6的顺序执行,但是当threadA与threadB同时运行时,就有可能会出现语句以1,4,2,3,5,6的顺序运行,单看1,4,2,3就能发现问题了,threadA首先将x的值置为1,然后threadB又将x的值置为5,接下来threadA又读取了x的值并执行了自增操作,那么此时输出x的值就会发现我们期待的结果应当是2,但输出的结果是6,这就是因为在threadA运行的过程中,threadB同时也修改了共享变量x的值,引发了线程安全问题。
为了解决线程安全问题,可以声明变量为volatile的,也可以给共享变量加锁。
(2)volatile关键字
以volatile关键字声明一个线程共享变量,那么当该变量被修改时会立即更新到主存,该变量被线程操作前,线程会先去主存获取最新值。volatile关键字采用缓存一致性协议保证共享变量的可见性,同时也能在一定程度上保证指令的有序性。
例如,volatile关键字修饰变量x,则能保证操作x变量的语句前面的语句先于该语句执行,且他后面的语句也不会提前执行。
1.i=3
2.j=i+1
3.x=100+5000
4.k=12345
5.m=i+k
在语句3执行前,保证语句1,2已经执行完毕,但不保证一定以1,2的顺序执行(因为指令可能被重排序),并且,指令4,5一定在语句3之后执行,但也不保证一定以4,5的顺序执行。
volatile关键字的实现原理为,生成的汇编代码中会多出一个lock前缀指令,在一定程度上阻止随意的指令重排序(该变量的操作语句是个分水岭,它后面的语句不会跨过它跑到前面去执行),对写操作强制更新到内存,并使其他CPU中对应的缓存行无效。
volatile关键字可以应用于状态标记量。
(3)synchronized关键字
synchronized关键字修饰方法和代码块,被其修饰的方法和代码块同一时间只能由一个线程来执行,在该线程退出方法之前,其他线程只能阻塞。
完善前面的代码,将共享变量x抽象到一个类Share中,对其自增操作抽象为一个方法。
Share.class
public class Share {
private int x;
public Share(int x){
this.x = x;
}
public void doIncrease(){
x++;
System.out.println(x);
}
}
MyThread
public class MyThread extends Thread{
private Share share;
public MyThread(Share share){
this.share = share;
}
public void run(){
for(int i=0;i<10000;i++){
share.doIncrease();
}
}
}
Main
public class Main {
public static void main(String[] args){
Share share = new Share(0);//共享变量
Thread threadA = new MyThread(share);
Thread threadB = new MyThread(share);
threadA.start();
threadB.start();
}
}
Main线程创建了两个线程threadA和threadB,操作共享变量share对象,运行后可以看到输出出现了问题。
发生这个问题的原因是,两个线程同时修改一个对象,threadA将x置为44之后,在threadB中,x仍为43,并未更新,因此threadB将x自增之后的44,又写了回去,值就重复了。为了解决这个问题,我们在doIncrease()前加上synchronized关键字。
public synchronized void doIncrease(){
x++;
System.out.println(x);
}
在threadA操作共享变量share前,需要先获得share的锁,然后才能执行doIncrease(),若此时threadB也需要调用doIncrease()操作share,因为获取不到锁,就会陷入阻塞,等待threadA的本次调用结束,释放锁之后,threadB才能尝试去获取share的锁,这样也就不存在threadA修改share的同时,threadB也去修改。
那这个锁是咋回事呢,在每一个java对象中,都有一个成员指向该对象的monitor对象,这个monitor对象会监视该对象的变化,控制线程对该对象的操作。monitor的结构如下:
monitor分为3部分,owner,entryList,waitSet。Owner保存了当前获得了对象锁并正在运行的线程,意思是被synchronized关键字约束的代码,在同一时间,只能有一个线程能够执行,这个线程就位于对象的owner中。
此时若有其他线程需要操作该对象,但是由于对象的锁正在被owner里的线程占用,所以只能处于阻塞状态,这些被阻塞了的线程就位于entryList中。只有当owner里的线程释放了锁之后,被阻塞的线程中的一个才有资格获得对象锁,进入owner。
当owner中的线程调用了wait()/join(),则会释放对象锁并从owner退出,进入该对象的waitSet中,此时该线程处于等待状态。
假如owner中的线程调用了notify(),则会随机的在waitSet中唤醒一个线程,该线程随即进入阻塞状态,等待获取对象锁,需要注意的是,虽然这个线程是阻塞态,但仍位于waitSet中,不会转移到entryList里。
若owner里的线程调用的是notifyAll(),则会唤醒waitSet中的所有线程,这些线程都会进入到阻塞态,等待获取对象锁。
由于在同一时间只能有一个线程操作线程共享变量,因此保证了线程的安全性,但是获取锁和释放锁的过程还是非常消耗资源的,因此,能不用锁的时候就尽量不用。
同时,java对锁进行了一些优化,膨胀路线为:无锁–>偏向锁–>轻量级锁–>重量级锁,需要在执行前获取锁,执行后释放锁,是指互斥锁,即重量级锁,是最高等级的锁。
– 锁消除:java内部会消除代码中不必要的锁,如果加了锁的代码,从来都不会产生线程之间互相竞争的情况,那就消除掉这个锁。
– 偏向锁:
偏向锁是Java 6之后加入的新锁,它是一种针对加锁操作的优化手段,经过研究发现,在大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得,因此为了减少同一线程获取锁(会涉及到一些CAS操作,耗时)的代价而引入偏向锁。偏向锁的核心思想是,如果一个线程获得了锁,那么锁就进入偏向模式,此时Mark Word 的结构也变为偏向锁结构,当这个线程再次请求锁时,无需再做任何同步操作,即获取锁的过程,这样就省去了大量有关锁申请的操作,从而也就提供程序的性能。所以,对于没有锁竞争的场合,偏向锁有很好的优化效果,毕竟极有可能连续多次是同一个线程申请相同的锁。但是对于锁竞争比较激烈的场合,偏向锁就失效了,因为这样场合极有可能每次申请锁的线程都是不相同的,因此这种场合下不应该使用偏向锁,否则会得不偿失,需要注意的是,偏向锁失败后,并不会立即膨胀为重量级锁,而是先升级为轻量级锁。下面我们接着了解轻量级锁。
– 轻量级锁:
倘若偏向锁失败,虚拟机并不会立即升级为重量级锁,它还会尝试使用一种称为轻量级锁的优化手段(1.6之后加入的),此时Mark Word 的结构也变为轻量级锁的结构。轻量级锁能够提升程序性能的依据是“对绝大部分的锁,在整个同步周期内都不存在竞争”,注意这是经验数据。需要了解的是,轻量级锁所适应的场景是线程交替执行同步块的场合,如果存在同一时间访问同一锁的场合,就会导致轻量级锁膨胀为重量级锁。
– 自旋锁:
轻量级锁失败后,虚拟机为了避免线程真实地在操作系统层面挂起,还会进行一项称为自旋锁的优化手段。这是基于在大多数情况下,线程持有锁的时间都不会太长,如果直接挂起操作系统层面的线程可能会得不偿失,毕竟操作系统实现线程之间的切换时需要从用户态转换到核心态,这个状态之间的转换需要相对比较长的时间,时间成本相对较高,因此自旋锁会假设在不久将来,当前的线程可以获得锁,因此虚拟机会让当前想要获取锁的线程做几个空循环(这也是称为自旋的原因),一般不会太久,可能是50个循环或100循环,在经过若干次循环后,如果得到锁,就顺利进入临界区。如果还不能获得锁,那就会将线程在操作系统层面挂起,这就是自旋锁的优化方式,这种方式确实也是可以提升效率的。最后没办法也就只能升级为重量级锁了。