Thread类及常见方法(续前一篇)
1.获取当前线程引用
2.休眠当前线程
就绪队列:
阻塞队列:
线程的状态
(1)NEW:
(2)TERMINATED:
(3)RUNNABLE:
(4)WAITING:
(5)TIMED_WAITING:
(6)BLOCKED:
多线程的意义
为啥使用多线程能快?
※多线程带来的风险-线程安全
为什么会出现不安全的情况/
能否消除随机性?
关于线程安全的代码案例:
那么为什么会出现这个问题呢?
问题出现在count++,++操作的本质上可以分成三步
线程安全的概念
线程不安全的原因
1.【根本原因】
2.【代码结构】
【注意理解】
3.【原子性】
4.【内存可见性】
5.指令重排序(本质上是编译器优化出bug了)
加锁!! synchornized
synchronized 的使用方法
1.修饰方法
(1)修饰普通方法
(2)修饰静态方法
2.修饰代码块
在哪个线程调用,就能获取到哪个线程的实例。
方法 | 说明 |
public static Thread currentThread(); | 返回当前线程对象的引用 |
static 静态的,但是在这里应该叫类方法是更好。
什么是类方法?
调用这个方法,不需要实例,可以直接通过类名来调用。
例如:Thread t = new Thread();
可以直接:Thread.currentThread() 不一定非要t.currentThread() 返回值正是t这个引用所指向的对象。
让线程休眠,本质上就是让线程不参与调度,不去CPU上执行(让线程对应的pcb暂时不去参加cpu的调度)。
因为线程的调度是不可控的,所以这个方法只能保证休眠时间大于等于参数设置的休眠时间。
链表里的PCB都是”随叫随到“,就绪状态。OS每次需要调度一个线程去执行就从就绪队列中选一个就好了。
线程A调用sleep,A就会进入休眠状态,把A从上述链表里拎出来,放到另一个链表中,此时,这个”另一个“链表的pcb都是”阻塞状态“,暂时不参与cpu的调度执行。
PCB是使用链表来组织的,实际上是使用了一系列以链表为核心的数据结构。
一旦线程进入了阻塞状态,对应的PCB就进入了阻塞状态,此时就暂时无法参与调度了。
当调用sleep(1000),对应的线程PCB就要再阻塞队列中待1000ms这么久,当这个PCB回到了就绪状态的时候也无法再唤醒之后立即就执行,实际上的间隔时间是要大于1000ms的·(调度也是需要开销的)。
状态针对的是当前线程调度的情况来描述的。(线程是调度的基本单位,状态更应该是线程的属性了),进程的属性前面介绍了三种,分别是:就绪状态,阻塞状态,正在运行的状态。
对于线程来说,在java中进行了具体的细化:
安排了工作还未开始(创建了Thread对象,但是还未调用start,也就是内核里没创建对应的PCB)
终止态,工作完成了。(表示内核中的pcb已经执行完毕但是Thread对象还在)
一旦内核里的线程PCB消亡了,此时代码里的t对象也就没什么用了,之所以存在也是迫不得己。是java中对象的生命周期的规则,所以就会存在内核中的PCB没了,但是代码中的PCB还在的情况,此时就需要通过特定的状态把t标志为“无效”。
可运行的(又可分为正在CPU上执行的和在就绪队列里随时可以去CPU上执行的)
排队等着其他事情
Scanner的阻塞是因为等待IO的,等待IO可能会进行一些线程操作,内部可能会涉及到锁操作或者是wait的一些操作。
排队等着其他事情
排队等着其他事情
查看当前的进程的状态:getState()
public class ThreadD11 {
public static void main(String[] args) throws InterruptedException {
Thread t = new Thread(() -> {
for (int i = 0; i < 10000; i++) {
}
});
System.out.println("start之前:" + t.getState());
t.start();
for (int i = 0; i < 10000; i++) {
System.out.println("start执行中:" + t.getState());
}
t.join();
System.out.println("start之后:" + t.getState());
}
}
输出结果:
那么,多线程的意义到底是什么呢?
下面来举一个代码实例来体会下单个线程和多个线程之间的执行速度的差别:
同样是输入数据,进行++操作,
首先是串行的:
public static void serial(){
long beg = System.currentTimeMillis();
long a = 0;
for (long i = 0; i <1000_000_000L ; i++) {
a++;
}
long b = 0;
for (long i = 0; i <1000_000_000L ; i++) {
b++;
}
long end = System.currentTimeMillis();
System.out.println("执行时间" + (end - beg) +"ms");
}
输出结果:
接下来是进行并发操作的:
public static void currency(){
Thread t1 = new Thread(() -> {
long a = 0;
for (long i = 0; i < 1000_000_000L ; i++) {
a++;
}
});
Thread t2 = new Thread(() -> {
long b = 0;
for (long i = 0; i < 1000_000_000L ; i++) {
b++;
}
});
long beg2 = System.currentTimeMillis();
t1.start();
t2.start();
try {
t1.join();
t2.join();
} catch (InterruptedException e) {
e.printStackTrace();
}
long end2 = System.currentTimeMillis();
System.out.println("并发时间" +(end2 - beg2) +"ms");
}
结果:
在这两部分代码需要记住的相关知识点:
(1)current.TimeMills(); 获取当前系统毫秒级时间戳
(2)多线程在IO密集型的任务中,也是有作用的。“程序未响应”
程序进行了一系列耗时的操作,(启动时要加载数据文件,就涉及到大量的读硬盘操作,阻塞了界面的响应,这种情况下使用多线程也是可以改善的,一个线程负责IO,另一个线程来响应用户的操作。
我们可以很明显的看出来最后的时间减少了很大一部分。 但是为啥不是正好缩短一半呢?
多线程可以充分的利用到多核心cpu资源。这里的t1 和 t2 也不一定是分布在两个CPU上执行的,不能保证一定是并行执行而不是并发。在t1和t2执行的过程中,会经历很多次的调度,这些调度有些是并发执行(在一个核心上),有些是并行执行(在两个核心上)。另一方面,线程调度自身也是有时间消耗的,虽然缩短了不是百分之50,但是仍然很明显,仍然是有意义的。至于到底多少次是并发,多少次是并行是不好预估的,这取决于系统的配置,也取决于当前的程序运行环境(系统的同一时刻跑了很多程序,并行的概率就会更小,有更多的人来抢CPU)
这部分知识是多线程编程中最难,最重要的部分。
多线程的抢占式执行带来的随机性!!
如果没有多线程,此时的代码执行顺序就是固定的,只有一条路,代码的顺序固定,程序的结果就是固定的,【单线程的情况下,只需要清楚这一条路即可】,如果有了多线程,此时抢占式执行,代码的顺序会出现更多的变数,代码的执行顺序就会从一种执行顺序变成了无数种执行顺序的情况。只要有一种情况下,代码的结果不正确,就都视为是有bug的,也就是线程不安全。
调度的源头来自于OS内核实现的,首先是不能更改的,其次改了的话别人也不会认同。
class Counter2{
public int count = 0;
public void add(){
count++;
}
}
public class ThreadD13_1 {
public static void main(String[] args) {
Counter2 counter2 = new Counter2();
Thread t1 = new Thread(() ->{
for (int i = 0; i < 5000; i++) {
counter2.add();
}
});
Thread t2 = new Thread(() ->{
for (int i = 0; i < 5000; i++) {
counter2.add();
}
});
t1.start();
t2.start();
try {
t1.join();
t2.join();
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
System.out.println(counter2.count);
}
}
运行上面的代码,我们可得到:
发现每次的结果都不一样,且和我们预期的结果也不同,以上是一个典型的线程安全问题。
(1)先把内存中的值,读取到CPU寄存器中 load
(2)把CPU寄存器的数值进行+1运算 add
(3)把得到的结果全部都写回内存中 save
load add save 这三个操作就是CPU上执行的三个指令。(视为是机器语言)
如果是两个线程并发的执行count++,此时就相当于两组 load add save 进行执行。此时不同的线程调度顺序就可能会产生一些结果上的差异。
不会出现问题的执行方式:(两个线程是串行的)
除此之外还有一些其他的执行方式就会出现bug:(出现问题的关键是这俩load操作,t1load
执行,t2load的值是t1修改之前的值,导致了t2后续保存数据的时候就会和t1打架)
还有一种就是t1执行了一次++的时候,t2走了两次++;
想给出一个线程安全的确切概念是困难的,但是可以认为:如果多线程环境下代码运行的结果是符合我们预期的,即在单线程环境应该的结果,则说这个程序是线程安全的。
抢占式执行,随机调度
多个线程同时修改一个变量
一个线程修改一个变量是可以的
多个线程读取一个变量是可以的
多个线程修改多个不同的变量也是可以的
如果修改的操作是原子的,可以。如果是非原子的,出现问题的概率就高了。
原子:不可拆分的基本单位。就像count++操作中的add,load,save就是无法在分的单个指令。
一个线程读,一个线程改(后续会记录
编译器自作主张在保持逻辑不变的情况下调整了代码的执行顺序,从而加快程序的执行效率。
如何从原子性的角度入手来解决线程安全问题?
加上synchornized后运行:
加锁的本质是保证原子性,其实也不是说让这里的三个操作一次完成,也不是这三步操作过程中不能进行调度,而是让其他也想操作的线程进行阻塞等待。
加锁的本质就是把并发变成串行。
进入方法就加锁,离开方法就解锁。修饰普通方法,锁对象就是this,修饰静态方法,锁对象就是类对象(Counter.class)
显式/动指定锁对象
加锁一定要明确是给哪个对象加锁的。如果两个线程针对同一个对象加锁,就会产生阻塞等待(锁竞争.冲突)
如果两个对象针对不同对象进行加锁,不会阻塞等待(不会锁冲突.锁竞争)
无论这个对象是个啥样的,原则就是一条:锁对象相同,就会产生阻塞等待,锁对象不同,就不会产生阻塞等待。
还是两个线程,一个线程加锁,一个线程不加锁,这个时候是否有锁竞争呢?是没有的!
关于让synchronized修饰代码块:
可以写成:
就代表当执行进入代码块的时候就加锁,出代码块就解锁。这里可以指定任意的对象,也不一定是this。
进入sychronized 修饰的代码块相当于加锁;
退出sychronized修饰的代码块相当于解锁。
需要注意的是除了JAVA这里的加锁解锁外,其余的很多i语言中的加锁和解锁往往是两个分开的操作。所以synchronized这种基于代码块的方式就有效的解决了上述的问题。
一个线程针对同一个对象,连续加锁两次,是否会有问题,如果没问题就是可重入的,如果有问题就是不可重入的。
例如代码:
解释:这里的锁对象是this,只要有线程调用add,进入add方法的时候就会先加锁(这时能够加锁成功),紧接着执行到了代码块部分,再次尝试进行加锁。
站在this(锁对象)的角度,它认为自己已经被另外一个线程占用了,这里的第二次加锁操作是否要阻塞等待呢?
如果允许上述的操作就证明这个锁是可重入的,如果不允许上面的操作就证明这个锁是不可重入的。如果不可重入,就会产生死锁的问题。
因为在JAVA种很容易出现上述的类似形式的代码,所以sychronized被JAVA设置成是可重入的。
但是在其他的语言中C++,Python操作系统原生的锁都是不可重入的。
【故事:从卫生间出来后,想再次进入,发现门被在里面反锁,但是并没有其他的人在里面,这是就变成了死锁问题。】
以上就是本篇博客的全部内容,关于锁的更多内容将体现在后续的博客中~