上一篇文章中介绍了多线程的基本使用,这一篇文章重点介绍线程的安全问题。
四、Java中线程的五种状态
- 新建状态(New):当线程对象对创建后,即进入了新建状态,如:Thread t = new MyThread();
- 就绪状态(Runnable):当调用线程对象的start()方法(thread.start();),线程即进入就绪状态。处于就绪状态的线程,只是说明此线程已经做好了准备,随时等待CPU调度执行,并不是说执行了thread.start()此线程立即就会执行;
- 运行状态(Running):当CPU开始调度处于就绪状态的线程时,此时线程才得以真正执行,即进入到运行状态。
注意:就绪状态是进入到运行状态的唯一入口,也就是说,线程要想进入运行状态执行,首先必须处于就绪状态中; - 阻塞状态(Blocked):处于运行状态中的线程由于某种原因,暂时放弃对CPU的使用权,停止执行,此时进入阻塞状态,直到其进入到就绪状态,才 有机会再次被CPU调用以进入到运行状态。根据阻塞产生的原因不同,阻塞状态又可以分为三种:
(1) 等待阻塞:运行状态中的线程执行wait()方法,使本线程进入到等待阻塞状态;
(2) 同步阻塞 -- 线程在获取synchronized同步锁失败(因为锁被其它线程所占用),它会进入同步阻塞状态;
(3)其他阻塞 -- 通过调用线程的sleep()或join()或发出了I/O请求时,线程会进入到阻塞状态。当sleep()状态超时、join()等待线程终止或者超时、或者I/O处理完毕时,线程重新转入就绪状态。 - 死亡状态(Dead):线程执行完了或者因异常退出了run()方法,该线程结束生命周期。
介绍一下上面的几个方法:
- thread.join():把指定的线程加入到当前线程。比如在线程B中调用了线程A的Join()方法,直到线程A执行完毕后,才会继续执行线程B。
- thread.sleep(时间长度):线程睡眠指定的一段时间。比如sleep(1000); 那么线程就会从Running状态变为blocked状态,时间为1s。一秒之后线程再变为Runnable,等等cpu重新调度。
- wait:wait有两种形式wait()和wait(milliseconds)。作用是讲线程挂起,进入阻塞状态。当wait超时(也就是wait(milliseconds)时间到了)或者调用了notify()/notifyAll()方法,会重新回到Running状态。
- thread.yield( ):很简单的作用,就是让线程的状态从Running状态变为Runnable状态,重新等待CPU调度。也可以翻译为线程让步。因为在多个线程同时执行的时候,cpu在同一时间,会从要执行的线程中选择一个线程去执行。当CPU选择了一个线程并在执行的时候,这个线程调用了yield()方法,就是告诉CPU:“你重新调度一次”。那么CPU就会重新调度选择要执行的线程,下一次执行的线程可能是其他线程,也可能还是这个线程。
- interrupt:中断。
- sleep和wait的区别:①sleep方法是Thread的方法,而wait方法是Object的方法是在别的线程中调用的。②sleep方法没有释放锁,而wait方法释放了锁。③sleep必须捕获异常,而wait,notify和notifyAll不需要捕获异常。④wait,notify和notifyAll只能在同步控制方法或者同步控制块里面使用,而sleep可以在任何地方使用。
五、线程安全
线程安全是指在并发的情况之下,该代码经过多线程使用,线程的调度顺序不影响任何结果。反过来,线程不安全就意味着线程的调度顺序会影响最终结果。简单的说就是共享的资源被多个线程同时操作时数据同步的问题。
理解线程安全,首先要理解Java内存模型的三个概念:
- 可见性:一个线程修改的状态对另一个线程是可见的。多个线程之间是不能直接互相传递数据的,它们之间数据交换是通过共享内存来进行。可以理解为主线程会分配一个主内存,而子线程会分配对应的工作内存。当子线程要操作主内存中变量的值得时候,其实是有这么一个过程:
(1) 从主内存复制变量到当前工作内存 (read and load)
(2) 执行代码,改变共享变量值 (use and assign)
(3) 用工作内存数据刷新主存相关内容 (store and write)
当一个共享变量在多个线程的工作内存中都有副本时,如果一个线程修改了这个共享 变量,那么其他线程应该能够看到这个被修改后的值,这就是多线程的可见性问题。 - 原子性:即最小的一步操作,也称为原子操作。比如a=0,这个一步就能完成,是原子操作。但是a=a+1,就需要不止一步,就不是原子操作。
- 有序性:明白了上面对于共享变量的操作过程,那么就会有一个问题:如果多个线程同时对一个共享变量进行操作,那么这个共享变量的值很可能因为不能实时更新,从而导致最终的数据不准确。
比如下面这个银行取钱的例子:
public class Account {
private int balance;
public Account(int balance) {
this.balance = balance;
}
public int getBalance() {
return balance;
}
public void add(int num) {
balance = balance + num;
}
public void withdraw(int num) {
balance = balance - num;
}
public static void main(String[] args) throws InterruptedException {
Account account = new Account(1000);
Thread a = new Thread(new AddThread(account, 20), "add");
Thread b = new Thread(new WithdrawThread(account, 20), "withdraw");
a.start();
b.start();
a.join();
b.join();
System.out.println(account.getBalance());
}
static class AddThread implements Runnable {
Account account;
int amount;
public AddThread(Account account, int amount) {
this.account = account;
this.amount = amount;
}
public void run() {
for (int i = 0; i < 200000; i++) {
account.add(amount);
}
}
}
static class WithdrawThread implements Runnable {
Account account;
int amount;
public WithdrawThread(Account account, int amount) {
this.account = account;
this.amount = amount;
}
public void run() {
for (int i = 0; i < 100000; i++) {
account.withdraw(amount);
}
}
}
}
每一次执行的结果都是不确定的,因为线程执行的是JVM调度的,存在不确定性。所以提出了有序性这个概念。
- synchronized关键字 ['sɪŋkrənaɪzd]
synchronized用来保证线程的有序性,使用方法如下:
synchronized(锁){
临界区代码
}
这时候就用药使用的共享变量作为锁,然后再临界区中加入对共享变量的相应操作。
还有一种方式是用synchronized来修饰一个方法,比如:
//表示这个这个方法所在的class的实例对象是锁
public synchronized void withdraw(int num) {
balance = balance - num;
}
//表示这个这个方法所在的class是锁,和上面的那个方法是有区别的
public static synchronized void withdraw(int num) {
balance = balance - num;
}
- 锁的概念
通过synchronized就可以对共享变量进行加锁。每个锁对象都有两个队列,一个是就绪队列,一个是阻塞队列。如果一个线程持有了锁,那么它就是就在就绪队列,就可以对共享变量进行操作。这个其他线程要操作这个变量的时候,发现它有锁,就会处于阻塞blocked状态,进入阻塞队列。等待之前持有锁的线程操作完成后,然后由JVM进行调度下一个线程由谁来执行。
还是以上面那个银行取钱和存钱的为例。当线程a调用了存钱的方法,那么就持有了锁。线程b去调用取钱的方法时,因为a还没有释放锁,所以就会在阻塞状态,进入阻塞队列中,等待a释放锁。 - volatile关键字['vɒlətaɪl]
volatile修饰的变量不允许线程内部缓存和重排序,即直接修改内存,从而实现多个线程之间的可见性。这里需要注意一个问题,volatile只能让被他修饰内容具有可见性,但不能保证它具有原子性。比如 volatile int a = 0;之后有一个操作 a++;这个变量a具有可见性,但是a++ 依然是一个非原子操作,也就是这个操作同样存在线程安全问题。
六、死锁
- 死锁的概念:在上一部分我们知道为了线程安全,一个资源可以通过加锁的方式来保证同一时间只能被一个线程所占有。举个例子,有T1和T2两个线程,有R1和R2两个共享资源。T1在运行时会使用到R1和R2,T2也会使用到R1和R2。这时前提条件。假设在有个时间点上,T1持有了R1的锁,T2持有了R2的锁。T1要继续执行就必须用到R2,但是R2已经被T2持有锁,T1就会阻塞。同样T2要继续执行就必须用到R1,但是R1已经被T1持有锁,T2就会阻塞。这样就形成了一个死循环,我们成为死锁。
所谓死锁,是指多个进程循环等待它方占有的资源而无限期地僵持下去的局面。 - 死锁产生的条件。要先明白死锁产生的条件,只要让产生条件不满足,就不会产生死锁的现象了。产生死锁的条件有4个:
①互斥:即某个资源在一段时间内只能由一个进程占有。
②不可抢占:资源申请者不能强行地从资源占有者手中夺取资源,而只能由该资源的占有者进程自行释放。
③占有且申请:进程至少已经占有一个资源,但又申请新的资源;由于该资源已被另外进程占有,此时该进程阻塞;但是,它在等待新资源之时,仍继续占用已占有的资源。
④循环等待:存在一个进程等待序列{P1,P2,...,Pn},其中P1等待P2所占有的某一资源,P2等待P3所占有的某一源,......,而Pn等待P1所占有的的某一资源,形成一个进程循环等待环。 - 死锁的预防
(1)避免不可抢占:当一个进程已占有了某些资源,它又申请新的资源,但不能立即被满足时,它必须释放所占有的全部资源,以后再重新申请。它所释放的资源可以分配给其它进程。
(2)线程在运行前申请得到所有的资源,否则该不能进入准备执行状态。但是它的缺点是可能导致资源利用率和进程并发性降低。而且有时候线程也不能只能它一共需要多少资源。
(3)避免交叉锁:避免已经占有了一个锁的同时,再去申请另一个锁。尽可能减少锁的范围。 - 死锁的恢复
如果我们在死锁检查时发现了死锁情况,那么就要努力消除死锁,使系统从死锁状态中恢复过来。消除死锁的几种方式:
(1)系统重启。这个最简单暴力,但是代价非常大。
(2)撤消进程,剥夺资源。终止参与死锁的进程,收回它们占有的资源,从而解除死锁。
(3)进程回退策略。让参与死锁的进程回退到没有发生死锁前某一点处,并由此点处继续执行。虽然这是个较理想的办法,但是操作起来系统开销极大,要有堆栈这样的机构记录进程的每一步变化,以便今后的回退,有时这是无法做到的。