在之前的文章中,我们了解到了多线程的基本用法,那么接下来让我们一起深入的了解一下多线程的其他相关知识吧!若已经忘了线程的基本用法了,请移步这里(❁´◡`❁)Thread类及常见的基本用法
新建了一个Thread对象,但是还没有调用 start() 方法
示例如下:
package thread;
public class Demo {
public static void main(String[] args) {
Thread t=new Thread(()->{
});
System.out.println(t.getState());//通过getState()这个方法获取到指定线程(也就是t)的状态
t.start();
}
}
操作系统中的线程已经执行完毕,销毁了;但是Thread对象还在时获取到的状态
示例如下:
package thread;
public class Demo {
public static void main(String[] args) throws InterruptedException {
Thread t=new Thread(()->{
});
t.start();
Thread.sleep(1000);//由于不知道是main先执行还是t先执行,所以为了拿到该状态,让它一秒钟之后再来拿
System.out.println(t.getState());
}
}
上面两种状态是Java内部搞出来的状态,和操作系统中的PCB里的状态没啥关系。
这个也就是我们所说的就绪状态(意味着两种情况:一个是它正在cpu上执行,一个是它还没执行,随时可以被调用到cpu上执行),处于这个状态的线程,就是在就绪队列中,随时可以被调度到CPU上。
如果代码中没有进行sleep,也没有进行其他的可能导致阻塞的操作,那么代码大概率是处在Runnable状态的。
示例如下:
package thread;
public class Demo {
public static void main(String[] args) throws InterruptedException {
Thread t=new Thread(()->{
while (true){
//这里什么都不能有,否则就可能得不到这个状态
//此时一直持续不断的执行这里的循环,系统随时想调度它上cpu都是可以的
}
});
t.start();
Thread.sleep(1000);
System.out.println(t.getState());
}
}
在代码中调用了sleep,就会进入到TIMED_WAITING;join(超时时间)也会进入到。
意思就是当前的线程在一定时间之内,是阻塞的状态
示例如下:
package thread;
public class Demo {
public static void main(String[] args) throws InterruptedException {
Thread t=new Thread(()->{
while (true){
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
});
t.start();
Thread.sleep(1000);//由于不知道是main先执行还是t先执行,所以为了拿到该状态,让它一秒钟之后再来拿
System.out.println(t.getState());
}
}
当前线程在等待锁,导致了阻塞(阻塞状态之一)
当前线程在等待唤醒,导致了阻塞(阻塞状态之一)
想给出一个线程安全的确切定义是复杂的,但我们可以这样认为:
如果多线程环境下代码运行的结果是符合我们预期的,即在单线程环境一样的结果,则说这个程序是线程安全的。
package thread;
class Counter {
//这个变量就是两个线程要去自增的变量
public int count = 0;
public void increase() {
count++;
}
}
public class Demo1 {
private static Counter counter = new Counter();
public static void main(String[] args) throws InterruptedException {
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和t2都执行完了之后,再打印count的结果
//否则,main和t1 t2之间都是并发的关系,导致t1和t2还没执行,就先执行了下面的打印操作
t1.join();
t2.join();
System.out.println(counter.count);
}
}
上面的线程不安全的代码中, 涉及到多个线程针对 counter.count 变量进行修改. 此时这个counter.count是一个多个线程都能访问到的 “共享数据”。
那么count++到底干了啥呢?站在CPU的角度来看待,count++,实际上三个CPU指令:
1.把内存中的count的值,加载到CPU寄存器中(load);
2.把寄存器中的值,给+1(add);
3.把寄存器的值写回到内存的count中(save)
除去上述造成线程不安全原因之外,还有其他原因也有可能造成线程不安全:
针对有些操作,比如读取变量的值,只是对应一条机器指令,此时这样的操作本身就可以视为是原子的–
但是线程主要有三个操作:
1.把内存中的count的值,加载到CPU寄存器中(load);
2.把寄存器中的值,给+1(add);
3.把寄存器的值写回到内存的count中(save)
所以可以通过加锁操作,把线程操作过程中的三个操作打包成一个整体,也就是把好几个指令给打包成一个原子的,禁止线程互相穿插执行,此时就可以保证线程安全了 。
咱们写的很多代码,彼此的顺序,谁在前谁在后无所谓(当然,也有的代码是依赖前后顺序的),此时 编译器就会智能的调整这里代码的前后顺序,从而提高程序的效率,在保证逻辑不变的前提,再去调整顺序;如果代码是单线程的程序,编译器的判定一般都是很准,但是如果代码是多线程的,编译器也可能产生误判。
1.synchronized关键字
2.lock锁
synchronized的基本使用:
synchronized 本质上要修改指定对象的 “对象头”,从使用角度来看, synchronized 也势必要搭配一个具体的对象来使用。
synchronized 的特性:
synchronized 会起到互斥效果, 某个线程执行到某个对象的 synchronized 中时, 其他线程如果也执行到同一个对象 synchronized 就会阻塞等待。
所以 synchronized 也能保证内存可见性. 具体代码参见后面 volatile 部分
简单来说,当同一个线程针对同一个锁,连续加锁两次时,如果出现死锁,就是不可重入,如果不会死锁,就是可重入的。
代码示例如下:
synchronized public void increase() {
synchronized (this){
count++;
}
}
在上述代码中,外层先加了一次锁,里层又对同一个对象再加一次锁
外层锁: 进入方法,则开始加锁;这次能够加锁成功,当前锁是没有人占用的。
里层锁: 进入代码块,开始加锁,这次加锁不能加锁成功,因为锁被外层占用着呢。
得等外层锁释放了之后,里层锁才能加锁成功;但是外层锁要执行完整个方法,才能释放;而要想执行完整个方法,就得让里层锁加锁成功继续往下走~~
这就死锁了!!
可重入锁的意义:降低了程序猿的负担(使用成本,提高了开发效率);但是也带来了代价,程序中需要更高的开销(维护锁属于哪个线程,并且加减计数,降低了运行效率)
一个线程一把锁,两个线程两把锁,那么N个线程就有M把锁
那么如何避免环路等待呢?
只要约定好,针对多把锁加锁的时候,有固定的的顺序即可!!所有的线程都遵守同样的规则顺序,就不会出现环路等待!!!
volatile 修饰的变量,能够保证 “内存可见性”。
代码在写入 volatile 修饰的变量的时候:
代码在读取 volatile 修饰的变量的时候:
前面我们讨论内存可见性时说了, 直接访问工作内存(实际是 CPU 的寄存器或者 CPU 的缓存), 速度非常快,但是可能出现数据不一致的情况. 加上 volatile , 强制读写内存. 速度是慢了, 但是数据变的更准确了
代码示例:
在这个代码中
static class Counter {
public int flag = 0;
}
public static void main(String[] args) {
Counter counter = new Counter();
Thread t1 = new Thread(() -> {
while (counter.flag == 0) {
// do nothing
}
System.out.println("循环结束!");
});
Thread t2 = new Thread(() -> {
Scanner scanner = new Scanner(System.in);
System.out.println("请输入一个整数:");
counter.flag = scanner.nextInt();
});
t1.start();
t2.start();
}
// 执行效果
// 当用户输入非0值时, t1 线程循环不会结束. (这显然是一个 bug)
t1 读的是自己工作内存中的内容.
当 t2 对 flag 变量进行修改, 此时 t1 感知不到 flag 的变化.
如果给 flag 加上 volatile:
static class Counter{
public volatile int flg=0;
}
volatile 和 synchronized 有着本质的区别:volatile保证的是内存可见性,并不能保证原子性,只处理一个线程读,一个线程写的情况,而 synchronized 都能处理。
由于线程之间是抢占式执行的, 因此线程之间执行的先后顺序难以预知;
但是实际开发中有时候我们希望合理的协调多个线程之间的执行先后顺序。
完成这个协调工作, 主要涉及到三个方法:
注意: wait, notify, notifyAll 都是 Object 类的方法。
wait 做的事情:
wait 要搭配 synchronized 来使用,脱离 synchronized 使用 wait 会直接抛出异常。
wait 结束等待的条件:
代码示例:
public class Demo {
public static void main(String[] args) throws InterruptedException {
Object object=new Object();
synchronized(object){
System.out.println("wait前");
object.wait();
System.out.println("wait后");
}
}
}
这样在执行到object.wait()之后就一直等待下去,那么程序肯定不能一直这么等待下去了。这个时候就需要使用到了另外一个方法唤醒的方法notify()。
注意: wait 哪个对象就需要对哪个对象进行加锁
notify 方法是唤醒等待的线程。
public class Demo1 {
private static Object locker = new Object();//锁对象
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(()-> {
//进行wait
synchronized (locker) {
System.out.println("wait 之前");
try {
locker.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("wait 之后");
}
});
t1.start();
//为了让现象更明显 在 t1 和 t2 之间加个sleep操作
Thread.sleep(3000);
Thread t2 = new Thread(()->{
//进行 notify
synchronized (locker) {
System.out.println("notify 之前");
locker.notify();
System.out.println("notify 之后");
}
});
t2.start();
}
}
结果图如下:
wait 和 notify 都是针对同一个对象来操作的
例如现在有一个对象 t有10个线程,都调用了t.wait,此时10个线程都是阻塞状态,如果调用了t.notify,就会把10个其中的一个唤醒(唤醒哪个是不确定的) ;
针对 notifyAll,就会把所有的10个线程都给唤醒,wait 唤醒之后,就会重新尝试获取到锁(这个过程就会发生竞争) 。
因此,相对来说,更常用的还是 notify
其实理论上 wait 和 sleep 完全是没有可比性的,因为一个是用于线程之间的通信的,一个是让线程阻塞一段时间,唯一的相同点就是都可以让线程放弃执行一段时间。
但是为了面试的目的,我们还是来总结下: