文章目录
- 一、线程状态
- 线程状态分类
- 线程各状态剖析
- 线程状态的转移
- 二、线程安全
- 线程不安全举例
- 线程安全概念
- 线程不安全的原因剖析
- Java标准库中的线程安全类与不安全类
- 线程不安全问题的规避
- 方案一:加监视锁
- 方案二:加volatile关键字
- 方案三:使用wait 和notify/notifyAll方法
- 参考
一、线程状态
线程状态分类
首先说结论,线程的状态是一个枚举类型Thread.State。
这个枚举类型可以取的值有以下六种
- NEW:安排了工作,但是还没有开始行动
- RUNNABLE:可工作的,可以分为正在工作中和即将开始工作
- BLOCKED:表示排队等待其他事情
- WAITING表示排队等待其他事情
- TIMED_WAITING:表示排队等待其他事情
- TERMINATED:工作已完成
我们可以通过下边这样一个遍历( 状态组成的数组) 进行验证。
线程各状态剖析
1.NEW状态
NEW状态可以理解为初始状态。
对于已经存在的线程类,我们new一个实例并赋值给线程对象,这个线程就进入到了初始状态即NEW状态。
2.RUNNABLE状态
我们上边已经知道RUNNABLE状态可以分为即将工作的和正在工作的线程,所以我们不妨自己把它们理解成就绪的线程(ready)和正运行的状态(running),方便我们后边进行区分。
🎈对于ready的RUNNABLE状态,我们主要讨论处于就绪状态线程的来源。
- 来源一:当前线程调用start方法,这个线程就进入就绪状态
- 来源二:当前线程sleep()方法结束,其他线程join()结束,等待用户输入完毕,某个线程拿到对象锁,这些线程也将进入就绪状态。
- 来源三:当前线程时间片用完了,调用当前线程的yield()方法,当前线程进入就绪状态。
另外,我们可以理解为锁池里的线程拿到对象锁,就进入了就绪状态。
🎈对于running的RUNNABLE状态,我们差不多只需要掌握。线程状态变成运行中方式即可。
那就是负责线程调度的程序从可运行池中选择一个线程进行运行。
补充:线程调度是按照特定机制为多个线程分配cpu资源的使用权。线程调度分为分时调度和抢占式调度两种。java程序中线程调度是由JVM负责的,而JVM默认是使用的抢占式调度机制。
3.BLOCKED状态
BLOCKED状态也可以理解成为阻塞状态。
一般而言,阻塞状态是线程阻塞进入synchronized关键字修饰的方法/代码块(获取锁)时的状态。
这个synchronized关键字非常重要,它是解决多线程不安全问题的重要思路之一,我们在后边会详细讨论。
4.WAITING状态
WAITING状态也可以理解为等待状态。
我们主要掌握处于此状态下线程的特点。
等待状态下的线程的特点:不会被分配cpu资源,他们需要被显示的唤醒,否则将无休止的处于等待状态。
5.TIMED_WAITING状态
TIMED_WAITING状态也可以理解为限时状态。
我们也是只知道处于此状态线程的特点即可。
限时等待状态下的线程的特点:不会被分配cpu资源,但是与WAITING 状态不同的是,他不需要无休止的等待,一定时间内,没人唤醒,就会自动苏醒。
6.TERMINATED状态
TERMINATED状态又叫做终止状态。
什么时候会进入终止状态?当线程的run方法完成时,或者主线程的main线程完成,此线程就进入terminated状态。
需要强调的是,或许这个线程对象是ALIVE 的,但是它已经不是一个可以独立执行的线程了。一个线程一旦终止不能复生。
假设在一个已经终止的线程上调用start方法,会抛异常java.lang.illegalThreadStateException异常。
总的来说,BLCKED、WAITING和TW都是干的等待的工作,NEW、RUNNABLE、TERMINATED三个构成一条主线,是一个线程出生、活着到死亡的全过程。
线程状态的转移
首先,我们不难画出来这个过程,先new完有了这个线程,才有可能进入runnable状态,运行完了才能进入terminated状态。
又因为状态的转换是需要条件的,所以我们这里再进行一些补充说明,加一些“反应条件”。
然后主线完了,我们来看三个支线.其实剩余的三个状态都是在等待。只是造成的原因不同,或者说是“反应条件不同”。sleep方法导致的TW状态很好理解,因为我们在上一篇已经提到了sleep方法。而这里的WAITING是涉及到我们后边要说的wait、notify、notifyAll方法。同时,这里的BLOCKD状态涉及到后边的监视锁,相信学完后边的,我们就能更好的理解这些反应条件了。
二、线程安全
线程不安全举例
我们来看这样一个多线程代码
class Counter {
public int count = 0;
void increase() {
count++;
}
}
public class ThreadDemo18 {
public static void main(String[] args) throws InterruptedException {
final Counter counter = new Counter();
Thread t1 = new Thread(() -> {
for (int i = 0; i < 50000; i++) {
counter.increase();
}
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 50000; i++) {
counter.increase();
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(counter.count);
}
}
根据之前所学知识预想,最终打印的值应该是100000才对,事实真的如此吗?我们多次运行程序我们会发现有下边的结果。
显然,最终结果不到10_0000大概是在98900左右,这是什么原因呢?
其实这跟run方法里边的++操作有关。
又因为线程是抢占式执行,随机调度的,所以,很有可能我们在执行++操作中的某一个指令时,cpu被调度走了,然后我们还没有把本次add后的结果写入,另一个已经load了,即加载的太早了,最终得到的结果错误。
除此上述图解情况以外,还有很多,我们根本没办法枚举完。比如下边的,如果只有前两种会得到正确的答案,但是显然是不太可能的。
事实上,这就是多线程编程带来的风险,线程不安全。那到底什么是线程安全?线程不安全又是什么原因导致的呢?我们有没有办法规避这样的风险呢?如果没有办法规避有没有什么办法尽量避免吗?
我们来正式讨论这些问题。
线程安全概念
由于线程安全没有确切的定义,所以这里只是给出一种可行的线程安全的判定规则。
如果多线程环境下代码运行的结果与单线程环境下结果相同,那么我们就可以认为此代码是线程安全的。
线程不安全的原因剖析
常见的造成线程不安全的原因主要有以下几种,我们首先全部给出,再逐个解释。
- 修改共享数据
- 修改操作不是原子性的
- 抢占执行,随机调度(根本原因)
- 指令重排序
- 内存可见性问题
需要特别说明的是,除了这些原因,还可能有其他原因,我们 需要具体问题具体分析。再有就是,出现线程不安全是一个概率事件。不是说我们有上述行为,bug必然发生,也不是说不出现上述行为,bug不可能出现。
1.修改共享数据
修改共享数据,简单来说,其实也就是多个线程同时对一个变量进行修改。
比如,上边两个线程t1,t2同时对counter.count进行修改,又操作不是原子的,所以导致出bug。
2.修改操作不是原子性的
要理解好这个原因,我们就需要先明白什么是原子性。
什么是原子性?不可再拆分的基本单位。这种不可再分割的现象其实也可以叫做同步互斥,表示操作是互相排斥的。那既然这样,我们能否简单的认为一条java语句就是原子的?答案是否定的,java语句是高级语言,它会转成汇编语言最后转成机器语言,这也就意味着,一条java语句可能对应指令/机器语言。比如n++,其实这个操作是由三条指令组成的:①从内存把数据读到cpu②进行数据更新③把数据写回cpu。
具体就像上边例子一样,由于操作不是原子的,又因为多线程抢占执行、随机调度的,我们刚执行完第一个线程的load,cpu就被另一个线程抢走了,开始执行另一个线程的load,这样的话再写回去得到的结果肯定比线程安全的时候要小。
3.抢占执行,随机调度
这是根本原因,是由OS内核实现决定的。
目前我们广泛使用的、以及以后工作使用的OS基本都会有这个问题,所以我们这方面无能为力,只能接受。【不是技术问题】
4.指令重排序
本质上是编译器优化出了bug。
编译器是由大神实现的。当编译器觉得我们自己写的代码太low,就会对代码进行优化,保持逻辑不变的情况下,调整代码的执行顺序,从而加快程序的执行顺序。绝大多数时候是不会优化出错的,但是我们不排除特殊情况。
注意,上边例子中的++中几套load\add\save顺序的改变并不是指令重排序,那只是由于cpu被两个线程来回抢造成的结果。
5.内存可见性
与修改操作不是原子性的那个操作类似,我们要理解内存可见性对线程安全造成的影响,也需要首先弄明白可见性问题。
什么是可见性呢?可见性就是一个线程对共享变量值的修改,能够及时地被其他线程看到。
而对于一个变量而言,一个线程读另一个改,很可能是会出问题的,如下:
class MyCounter{
public int flag=0;
}
public class Code15_ThreadMemoryVQ {
public static void main(String[] args) {
MyCounter myCounter=new MyCounter();
Thread t1=new Thread(()->{
while(myCounter.flag==0){
}
System.out.println("循环结束");
});
Thread t2=new Thread(()->{
Scanner sc=new Scanner(System.in);
System.out.println("请输入一个整数:");
myCounter.flag=sc.nextInt();
});
t1.start();
t2.start();
}
}
我们预期的结果应该是t1处于RUNNABLE状态,t2线程启动后,输入整数非0值,对flag进行了修改,循环结束,打印“循环结束”字样,可是事实是这样吗?
在等待一段时间后,console控制台仍是这样
显然,这是出bug了,只是什么原因呢?其实这是因为JVM/编译器对多线程代码错误判断导致的,这句话怎么理解呢?
while(myCounter.flag==0)这句话的二进制指令其实是两条,分别是load(把内存中的值加载到寄存器中)和cmp(比较)。正如前面我们提到的cpu对于寄存器和对内存访问速度的差异,所以v(load)<<v(cmp),所以jvm做了一个大胆决定,把这些load判定为无效,不再重复load,只读取一次。这其实是一种编译器优化失败的结果。
Java标准库中的线程安全类与不安全类
❗不安全的类:
- ArrayList
- LinkedList
- HashMap
- TreeMap
- HashSet
- TreeSet
- StringBuilder
为了方便记忆,我们可以记成“最迷人的也最危险(ds七子)”。
因为这几个恰巧是我们前边数据结构重点实现或者使用的线性表的两个,Map&Set对应的四个,再加一个StringBuilder.
❗安全的类:
Vector(不推荐使用)、HashTable(不推荐使用)、ConcurrentHashMap、StringBuffer
这些标准库中类线程安全的原因就是使用了一些锁机制来控制。
除此以外,String没有加锁,但是由于是不可修改的(没有向外提供公开的接口),是天然线程安全的。
我们简单记成,VHCSS。V是Vector,H是Hashtable,C是ConcurrentHashMap,S是String,S是StringBuffer。
我们在这里很容易有问题,为什么线程安全的类不推荐使用?
因为线程安全是有代价的,我们这里利用锁机制和其他方法让它安全了,但同时也降低了代码的速度。
相较于线程安全的类,线程不安全的类,灵活性更高。
比如,我们在单线程环境下,使用这些线程不安全的、未被太多东西束缚的类效率更高;在多线程环境下,我们可以通过手动处理,尽可能的规避线程不安全的风险。
线程不安全问题的规避
前边已经提到,线程不安全是一个概率事件,所以我们不能完全肯定的说解决这个问题,只能说最大可能的避免。
方案一:加监视锁
可能很多人对线程安全问题中提到锁这个概念感觉一头雾水,其实我开始也是。不过我们一点一点剖析,就会发现好像也没那么难理解。
首先,我们明确加监视锁是为了解决什么问题。它是为了解决非原子性的问题的,通过加(监视)锁,我们就可以把非原子的操作转换成原子的操作。这么说或许你还觉得比较抽象。举个例子,你是一个古代人,要去做饭,那必须先要劈柴吧,你不能砍到一半说就不干了,你必须得把所有的做饭需要用的柴砍完了,才能够去做烧柴做饭后序操作吧,不然,你砍到一半,去烧了,诶,柴不够,火吧唧就灭了,你又去砍柴了,回来一看,饭凉了,又得重新烧火……最后做出来的饭很大几率就是不熟的。(忽略特殊情况,请勿杠)但是呢,假如,我们将人用个金钟罩箍到那,你必须砍完烧饭需要的柴,你才能离开去起火烧柴做饭,这个时候是不是很大可能降低了做出半生半熟的饭的几率。其实这个箍人就相当于加锁的动作,而这个金钟罩就是锁,也就相当于代码中的大括弧包起来的一团,锁对象就是人,这个时间内你到底做什么我不关心,但是你必须整套打下来,谁都不能把你带走。最终就达到降低错误几率的目的。
其次,监视锁,英文名叫monitor lock,实现的话就是通过synchronized关键字实现的。关于synchronized这个关键字,汉语意思就是同步的,很好理解,就是说被它括着代码必须一起执行,注意这里的一起不是同一时刻,而是说一部分执行了,另外一部分也必须执行,他们是一体的。
最后,我们可以理解为锁是一种权限,一种把锁对象圈到自己领地不被别人使用的权限,线程哥几个始终抢的都是锁对象,而不是锁本身,他们都在尝试对对象加锁这个行为,这个动作,这是一种权限,有这个权限的线程成功,没有权限的失败。所以我们后边会有其他线程获取锁对象/锁这两种说法,都是一个意思,一个是实体,一个是获得实体的权限,都是名词。
下边,我们就来看看synchronized这个关键字的具体用法。
(一)synchronized关键字的使用方法及示例
synchronized是起到加锁作用,那么加锁肯定需要有对象。就像我们“绑”着那个人,不让他去干其他事情,我们假设其他事情成精了,如果需要被它做就需要站在金钟罩外边候着。(这几个事情等价于其他线程的run方法里,人相当于对象,被来回争)。它总体上有三种用法,修饰普通方法,修饰静态方法,修饰代码块。(或者也可以说是两种,修饰方法,方法中有普通方法和静态方法,以及修饰代码块)锁对象,对于普通方法而言,它是隐含了一个this引用,所以此时synchronied的加锁对象就是this;对于静态方法的话,虽然没有this引用,但是每个类有一个唯一的类对象,这时它的加锁对象就是类对象,也是隐含的;代码块的加锁对象,就需要我们显示的指定的了,视情况而定。反映到上代码一般就是从this、类名.class(类对象)和普通对象变量中三选一。
补充:类名.this是内部类调用外部类对象的相关用法
总而言之,synchronized必须搭配一个具体对象使用。下边就是几个用法
-
直接修饰普通方法
对于位置的话,我们可以理解成这是一个形容词,和public是一个级别的(虽然从含义上并不是),都是形容词,谁在前在后都是一样的。
锁对象:this
-
修饰静态方法
synchronied在public前后都是一样,这里就只写一种了
-
修饰代码块
比如main方法里的代码块、add方法里的
锁对象:target2
锁对象:this
(二)synchronized关键字的特性/作用
-
互斥性:
其中一个线程已经拿到这把锁/这个被锁的对象了,其他线程也有尝试获取的机会,但是不好意思,无一例外,全部被拒绝,他们必须安静等待当前线程释放对象/锁,才能竞争。这也就相当于A线程拿到锁/对象,BCD线程尝试竞争,结果发现已经被占了,他们就必须进入对象/锁的阻塞队列中去,不可以强行开锁。或者说对于这几个BCD线程和A线程是磁石中NS级的关系
-
可重入
可重入就是同一个线程对于同一个对象,同时加锁两次或两次以上是否有问题。如果没问题,不产生bug,这把锁就是可重入的,反之这把锁就是不可重入的,这把锁也可以称为死锁。
死锁这个概念非常重要,内容也是不少,我在这里单独总结了一下,有需要的可以参考(下一篇)。
总而言之,对于这个synchronized,我们需要记住它的用法、特性。另外它的本质其实就是把可能出bug的多线程从并行变成串行了。
方案二:加volatile关键字
首先,我们明确volatile关键字是为了解决哪个问题的。它是为了解决内存可见性问题。
其次,volatile中文意思是易变的,其实在这里也是给JVM提醒,在这个做标志的地方注意。
(一)volatile的使用方法
修饰变量。与synchronized位置一样,可放在public前边,可放在后边。
加上关键字后,相当于给编译器提了个醒。
(二)volatile的使用示例
(三)synchronized和volatile区别
synchronized保证原子性,volatile不保证原子性,保证内存可见性。
方案三:使用wait 和notify/notifyAll方法
由于线程抢占执行和随机调度是由OS内核实现决定的,所以这个我们没办法改变。但是我们可以通过对对象调用wait、notify/notifyAll方法来控制线程的执行顺序,尽可能的达到我们的目的。
(一)wait作用
使当前线程阻塞在调用wait方法对象的阻塞队列中。主动让出cpu资源。
(二)wait方法使用实例
wait方法的工作内容:①先释放锁②进行阻塞等待③收到通知,重新尝试获取锁
因此,我们需要注意,要对对象调用wait方法这句代码用synchronied包裹即加锁。
另外与其他有阻塞功能方法类似的是,wait也可以通过interrupt异常唤醒,因此我们这里需要进行异常处理,这里我们采取的是声明中断错误异常的方式。
public class Code17_ThreadWait {
public static void main(String[] args) throws InterruptedException {
Object object=new Object();
synchronized (object) {
System.out.println("对象调用wait之前");
object.wait();
System.out.println("对象调用wait之后");
}
}
}
(三)notify方法的作用
通知同对象的阻塞队列中的首元素醒醒,开始工作了!!
(四)notify方法使用示例
跟wait方法类似的是,notify方法的首个工作内容也是释放锁,所以必须进行加锁处理。
另外,我们必须保证wait所在的线程在notify所在线程前边执行,不然notify所在的线程先执行了,也就是无效通知了,但wait所在的线程不就陷入死等了吗?一直在阻塞,就会有问题。所以,这里我们为了尽可能保证t1在t2之前执行,我们在t1线程启动之后和t2线程中都sleep,降低上述事件发生的概率。
public class Code18_ThreadWN {
public static void main(String[] args) throws InterruptedException {
Object object1=new Object();
Thread t1=new Thread(()->{
//等待线程
System.out.println("t1:wait之前");
try {
synchronized (object1) {
object1.wait();
}
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
System.out.println("t1:wait之后");
});
Thread t2=new Thread(()->{
//优先线程,
System.out.println("t2:notify之前");
//为保证notify获得锁,我们这里加锁,同时等待,确保前边的线程进入了等待状态
synchronized (object1) {
try {
Thread.sleep(3000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
object1.notify();
}
System.out.println("t2:notify之后");
});
t1.start();
Thread.sleep(1000);
t2.start();
}
}
(五)notifyAll方法
与notify功能类似,都是通知同对象的线程。但是略有区别的是,notify是随机唤醒一个同对象所在的阻塞线程(假设有多个线程),notifyAll是唤醒所有的,剩下的进行所竞争。
总而言之,这个风险还是比较大的,所以用的也是比较少。
❗wait和sleep的区别【面试题】
相同点:都可以让线程主动放弃线程执行(cpu资源争夺)一段时间
不同点:wait是用于线程之间通信的,sleep只是是让线程阻塞确定的时间。
其实没有可比性……
❗wait和join:obj.wait()和t.join()分析
obj.wait是让把当前线程放进obj对象的阻塞/等待队列中。【当前线程还活着】
t.join是让主线程等待t死亡。【主线程等t死亡】
参考
线程6种状态的理解