作者:爱塔居
专栏:JavaEE
作者简介:大三学生,希望和大家一起进步。
目录
文章目录
前言回顾
一、如何解决线程安全问题?
二、如何进行加锁
三、内存可见性引起的线程不安全
四、指令重排序
一、线程和进程的基本概念。
二、请说明Thread类中run和start的区别
作用功能不同:
运行结果不同:
三、Thread类用法,启动线程,终止线程,等待线程,获取线程引用,休眠线程。
四、线程的状态
NEW RUNNABLE TERMINATED TIMED_WAITING WAITING BLOCKED
争对这些基本的线程状态,我们要有一个基本的认识。这些状态对我们未来调试多线程的问题是有很大的帮助的。
五、线程安全(重点、难点)
一个代码在多线程环境下,执行会出问题,这就叫线程不安全。
为什么会出现线程安全问题?
本质原因:线程在系统中的调度是无序的/随机的。(抢占式执行)
1.抢占式执行(罪魁祸首)。
2.多个线程修改同一个变量。
一个线程修改同一个变量=>安全
多个线程读取同一个变量=>安全
多个线程修改不同的变量=>安全
3.修改操作,不是原子的。(原子性:不可分割的最小单位)
修改操作,可以拆分成三个操作——load、add、save
某个操作,对应单个cpu指令,就是原子的。如果这个操作对应多个cpu指令,大概率就不是原子的。
4.内存可见性,引起的线程不安全
5.指令重排序,引起的线程不安全
(4、5和当前的count++的例子无关)
join不能防止线程抢占执行吗?
这个思想是一个办法,不过如果这么搞,就不需要多线程了,直接一个线程串行执行。
多线程的初心:进行并发编程,更好地利用多核CPU
能否让count++操作变成原子的呢?这是一个核心的思路。
加锁。举生活中的例子,上厕所。
上厕所,打开门进去,把门锁了。上完厕所,解锁,打开门离开。
锁,就能起到保证“原子性”的操作。
锁的核心操作有两个
1.加锁
2.解锁
一旦某个线程加锁了之后,其他线程也想加锁,就不能直接加上了,就需要阻塞等待,一直等到拿到锁的线程释放锁了为止。
记得,线程调度,是抢占式执行的
当1号释放锁之后,等待的2和3,谁能抢先一步拿到锁,那是不确定的了。
1号上完厕所离开,2号和3号谁能进去,是不一定的。
此处的“抢占式执行”导致了线程之间的调度是“随机”的。
synchronized
synchronnized是java中的关键字,直接使用这个关键字来实现加锁效果。
()中的锁对象,可以是写作任意一个Object对象(内置类型不行),此处写了this,相当于
package threading;
class Counter{private int count=0;public void add(){synchronized (this){//加锁count++;}}public int get(){return count;}
}
public class ThreadDemo10 {public static void main(String[] args) throws InterruptedException{Counter counter=new Counter();//搞两个线程,两个线程分别对这个counter自增5w次Thread t1=new Thread(()->{for (int i = 0; i <50000 ; i++) {counter.add();}});t1.start();Thread t2=new Thread(()-> {for (int i = 0; i < 50000; i++) {counter.add();}});t2.start();t1.join();t2.join();System.out.println(counter.get());}
}
输出结果:
加锁的本质是把并发的变成了串行的。
join只是让两个线程完整地进行串行。加锁,两个线程的某个小部分串行了,大部分都是并发的。
在上述代码中,一个线程做的工作大概是这些:
1.创建i
2.判定i<50000
3.调用add
4.count++
5.add返回
6.i++
其中只有count++是串行的,剩下的12356两个线程仍然是并发的。
在保证线程安全的前提下,同时还能让代码跑的更快一些,更好地利用下多核cpu。
无论如何,加锁都可能导致阻塞。代码阻塞,对于程序的效率肯定还是会有影响的。此处虽然是加了锁,比不加锁要慢些,肯定是比串行快,比不加锁算的准。
package threading;
class Counter{private int count=0;
// public void add(){
// synchronized (this){//加锁
// count++;
// }
//
// }synchronized public void add(){count++;}public int get(){return count;}
}
public class ThreadDemo10 {public static void main(String[] args) throws InterruptedException{Counter counter=new Counter();//搞两个线程,两个线程分别对这个counter自增5w次Thread t1=new Thread(()->{for (int i = 0; i <50000 ; i++) {counter.add();}});t1.start();Thread t2=new Thread(()-> {for (int i = 0; i < 50000; i++) {counter.add();}});t2.start();t1.join();t2.join();System.out.println(counter.get());}
}
这样写,也是一样的。直接给方法使用synchronized修饰,此时就相当于以this为锁对象。
geng如果synchronized修饰静态方法,此时就不是给this加锁,而是给类对象加锁了。
synchronized public static void test(){
}
public static void test2(){
synchronized(Counter.class){
}
}
更常见的是手动指定一个锁对象。
private Object locker=new Object();
public void add(){synchronized (locker){count++;}
如果多个线程尝试对同一个锁对象加锁,此时就会产生锁竞争。针对不同对象加锁,就不会有锁竞争。
1.static 修饰的方法是什么意思?
方法不属于对象。方法分为:实例方法,类方法(静态方法)
2.类对象是什么吗
Counter.class
.java源代码文件。javac=>.class(二进制字节码文件)JVM就可以执行.class了。JVM要想执行这个.class就要先把文件内容读取到内存中(类加载)
类对象,就可以来表示这个.class文件的内容。
描述了类的方方面面的详细信息,包括不限于:
1.类的名字
2.类有哪些属性,属性的名字,类型,权限
3.类有哪些方法,方法的名字,参数,类型,权限
4.类继承自哪个类
5.类实现了哪些接口
……
此处类对象,就相当于“对象的图纸”
package threading;import java.util.Scanner;public class ThreadDemo11 {public static int flag=0;public static void main(String[] args) {Thread t1=new Thread(()->{while (flag==0){}System.out.println("循环结束!t1结束!");});Thread t2=new Thread(()->{Scanner scanner=new Scanner(System.in);System.out.println("请输入一个整数");flag=scanner.nextInt();});t1.start();t2.start();}
}
我们预期效果是,当我们输入非0的整数时,输出循环结束!t1结束! 退出循环
输入非0数之后,还在运行,并没有退出循环。
while循环,flag==0
load 从内存读取数据到cpu寄存器
cmp 比较寄存器里的值是否是0
此时,load的时间开销远远的高于cmp。读取内存虽然比读硬盘来的快,但是读寄存器,比读内存又要快。
此时,编译器就做了一个非常大胆的操作,把load就给优化掉了。只有第一次执行load才真正的执行了。后续循环都只cmp,不load(相当于是复用之前寄存器中的load过的值)
这是编译器优化的手段,是一个非常普遍的事情,能智能地调整你的代码执行逻辑,保证程序结果不变地前提下,语句变化,通过一些列操作,让整个程序执行的效率大大提升。
编译器对于“程序结果不变”单线程下判定是非常准确的。但是多线程不一定,可能导致调整后,效率提高,结果变了。
所谓的内存可见性就是多线程环境下,编译器对于代码优化,产生了误判,从而引起了bug,进一步导致了我们代码的bug。
此时,我们的处理方式,就是让编译器针对这个场景暂停优化。
如果加了sleep,循环执行速度就非常慢了。当循环次数下降了,此时load操作,不再是负担,编译器也没有必要优化了:
package threading;import java.util.Scanner;public class ThreadDemo11 {public static int flag=0;public static void main(String[] args) {Thread t1=new Thread(()->{while (flag==0){try {Thread.sleep(1000);
}catch (InterruptedException e){e.printStackTrace();
}}System.out.println("循环结束!t1结束!");});Thread t2=new Thread(()->{Scanner scanner=new Scanner(System.in);System.out.println("请输入一个整数");flag=scanner.nextInt();});t1.start();t2.start();}
}
输出结果:
volatile关键字
被volatile修饰的变量,此时编译器就会禁止上述优化,能够保证每次都是从内存重新读取数据。
package threading;import java.util.Scanner;public class ThreadDemo11 {volatile public static int flag=0;public static void main(String[] args) {Thread t1=new Thread(()->{while (flag==0) {}System.out.println("循环结束!t1结束!");});Thread t2=new Thread(()->{Scanner scanner=new Scanner(System.in);System.out.println("请输入一个整数");flag=scanner.nextInt();});t1.start();t2.start();}
}
volatile不保证原子性。volatile适用的场景,是一个线程读,一个线程写的情况。而synchronized是多个线程写的情况。
volatile还有一个效果,禁止指令重排序。
指令重排序,也是编译器优化的策略,调整了代码执行的顺序,让程序更高效。前提也是保证整体逻辑不变。
谈到优化,都要保证调整之后的结果和之前是不变得。单线程下容易保证,多线程就不好说了。
例子:
Student s
t1:
s=new Student();
t2:
if(s!=null)
s.learn();
t1中的语句大体可以分为三个操作:
1.申请内存空间——交钱
2.调用构造方法(初始化内存的数据)——装修
3.把对象的引用赋值给s(内存地址的赋值)——拿到钥匙
如果是单线程环境,此处就可以指令重排序:
1肯定先执行,2和3谁先执行,谁后执行,都可以。
如果t1按照 1 3 2的顺序执行,当t1执行完1 3 之后,即将执行2的时候,t2开始执行。由于t1的3已经执行过了,这个引用已经非空了。t2开始调用s.learn()。但是由于t1还没有初始化,learn的结果是什么,不知道了,就出现了bug。
这就是指令重排序的问题。
这个代码难以演示,因为大部分情况是正确的。