0817(033天 线程/进程04 线程安全)
每日一狗(田园犬西瓜瓜)
线程/进程04 线程安全
文章目录
1. 一级标题
1.1 Java内存模型 JMM
Java内存模型JMM规定和指引Java程序在不同的内存架构、CPU和操作系统间有确定性地行为。它在多线程的情况下尤其重要。Java内存模型对一个线程所做的变动能被其它线程可见提供了保证,它们之间是先行发生关系。这个关系定义了一些规则让程序员在并发编程时思路更清晰。
分成了主内存和工作内存
-
主内存:公共的
-
工作内存:线程私有的
-
线程在运行的时候,会把主内存中的数据拷贝到自己工作内存中(可能会在修改时导致数据不一致)
-
原子操作:操作本身无法拆分。
-
可见性
-
先行发生原则
1.2 线程安全
线程安全就是由于数据共享导致的。
共享数据在被修改时要先把数据从主内存拷贝到工作内存中间,在工作内存中进行加一操作后在写回主内存中,在他读取后其他线程在读取的数据相当于他这个线程来说就是一个老数据了,但是其他线程并不知道,就有可能导致数据不一致问题。
常见的不安全
- count++
- 单例的多次创建
常见解决方案
- 单个状态使用一些原子变量类,
- 加锁
1.3 栈和堆
2. 如何安全呢
2.1 voatile
效果:只用于描述属性,表示线程无法通过给自己工作内存拷贝一份来用过这个数据,要用就直接操作我主内存中的数据,不允许私藏数据。
只是保证了线程修改的结果对其他线程立马可见,但是保证不了值的一致性
特性
- 可见性:当一个线程在修改被voatile修饰的数据时,JMM会将修改的本地的拷贝数据及时刷新到主内存中,使其他线程立马可见。
- 保证有序性:当数据被voatile修饰时,JMM会禁止读写该数据变量前后语句的大部分指令重排(在不改变运行结果和代码顺序时编译器和处理器常常会将我们的程序进行重拍优化)优化
- 部分原子性:对任意单个volatile变量的读/写具有原子性,但类似于volatile++这种复合操作不具有原子性
浅尝一下
在flag属性不加 volatile之前主线程的循环会一直执行下去,虽然子线程已经在任务中将flag改为true了,但是主线程的本地内存中存储的还是false,他又不会在不修改的情况下去从主内存中同步数据,这就导致主线程的本地内存数据始终不能让他结束循环。
public class Test1 {
private static volatile boolean flag = false;
private static int i = 0;
public static void main(String[] args) {
new Thread(()->{
try {
TimeUnit.MILLISECONDS.sleep(100);//Thread.sleep(100)
flag=true;
System.out.println("flag changed...");
} catch (InterruptedException e) {
e.printStackTrace();
}
}).start();
while(!flag){
i++;
}
System.out.println("progress end...");
}
}
总结一下
- 不能保证原子性,(可能会导致不能一步到胃的操作会引发安全问题)
- 同步数据只不过是将本地内存中的数据的修改实时修改到主内存中,连这一步都不是原子的
3. 一些问题
3.1 问:线程状态切换
线程的几个状态:
初始状态:
由操作系统或其他线程提交任务到线程中,线程对象调用start方法告诉操作系统(我这个线程要执行了),线程进入可运行状态,等待系统分配CPU资源来执行线程。
可运行状态:
可运行状态线程同其他同状态的线程竞争CPU资源,拿到CPU资源的线程进入运行状态开始运行(win现在采用的是抢占式调度机制【释放用的是时间片轮转法】)。
运行状态:
运行状态的线程可以用(用户输入、
Thread.sleep();
、t2.join();
)来使自己进入阻塞状态,释放CPU资源;线程运行期间在(时间片用完后、调用Thread.yield();
、Thread.sleep(0L);
)主动释放CPU资源,自己也会从运行状态进入可运行状态,再次等待系统分配CPU资源,从而让其他线程拥有更多的执行可能,这种情况下该进程会同时和其他进程同时竞争CPU资源,所以只是给了其他线程机会,并不是不竞争了,该争还是得争(我就让让)。
阻塞状态:
线程进入阻塞状态,在达到某种需求(用户完成输入、睡觉时间结束了、另一个线程结束了)后,会立刻进入可运行状态同其他可运行状态线程竞争CPU资源。
等待队列:
当一个运行状态的线程调用
o.wait();
方法后会进入等待队列中,他会释放CPU资源和线程锁,自己在对列中等待(指定长时间、o.notify();//在队里中随机唤醒一个线程
、o.tifyAll();//将队列中的线程全部唤醒
)后去和其他需要竞争线程锁的线程一起进入锁池状态一起竞争线程锁的使用权。
锁池状态:
当运行状态的线程执行到了用synchonized锁来修饰的(代码块(那就是这段代码块)、方法(对象)、静态方法(类的所有对象)、类(所有实例化对象))时,就会进入锁池状态、或者从等待队列中醒来的线程也会进入锁池状态。所有的线程堵在竞争一把锁的的权限,有这把锁才能进入执行这段加锁的程序,拿到了锁的线程对象会进入到可运行状态同同状态线程竞争CPU资源。
终止状态:
当线程执行完毕或其他线程终止该线程时此线程会进入终止状态,同时线程状态。
进入终止状态的线程再次调用start方法试图启动线程执行任务时,线程会抛出一个java.lang.IllegalThreadStateException(非法线程状态异常)。
3.2 问:阻塞小妙招
sleep() 抱着锁睡觉
让当前线程休眠指定时间。休眠时间的准确性依赖于系统时钟和CPU调度机制。
如果需要可以通过调用interrupt()方法来唤醒休眠线程,而这个唤醒其实也是通过异常抛出来唤醒的,所谓的interrupt方法实际上会产生一个异常InterruptedException。
在线程休眠时间内,他并不会释放锁,他还会占着锁,所以他才能让使用他的阻塞的线程不用跟他一棒子锁池状态的线程去抢锁的使用权限。
TimeUnit.SECONDS.sleep(1); //休眠一秒
Thread.sleep(1000);// 休眠1000毫秒
Thread.sleep(0);// 休眠0毫秒 主要用于将CPU的资源释放出去,让其他线程拥有更多的运行机会
Thread.sleep(0);是有意义的,他会让出CPU资源,并且进入可运行状态,继续竞争CPU资源。变态的yield了一下
wait() 把锁释放了在去睡觉
会将自身拥有的锁进行释放,并进入等待队列中,这把锁就会供锁池状态的线程去竞争。
wait();wait(0); // 无限制休眠,除非有其他线程将其唤醒
wait(10L); // 休眠10毫秒从等待队列中放出来进入锁池专改中
notify()是随机唤醒单个线程,而notifyAll()是唤醒当队列中的所有线程。
要求:wait方法必须在同步上下文中调用,例如:同步方法块或者同步方法中,这也就意味着如果你想要调用wait方法,前提是必须获取对象上的锁资源;没有就会报错java.lang.IllegalMonitorStateException(非法监控状态异常)
sleep()和wait()的区别
都会阻塞线程
- sleep不会释放锁,wait会释放锁
- sleep时线程类中的静态方法,wait时Object类中的方法
- 休眠传参0
- Thread.sleep(0)是让出CPU,是当前线程从运转状态转到可运行状态。
- wait()和wait(0L)会无限期等待,
- 使用要求:
- sleep没有要求,随便用
- wait方法必须要求上下文中要有锁才行,不然就报错了
join 等待指定线程结束
主要作用是同步,它可以使得线程之间的并行执行变为串行执行。
具有可传递性,
调用者等待线程对象执行完毕后再继续执行
会释放锁
// join(long)内部使用wait(long)实现的所以join也会释放锁,但是sleep并不会释放锁
public final void join() throws InterruptedException {
join(0);
}
public final synchronized void join(long millis)
throws InterruptedException {
long base = System.currentTimeMillis();
long now = 0;
if (millis < 0) {
throw new IllegalArgumentException("timeout value is negative");
}
if (millis == 0) {
while (isAlive()) {
wait(0);
}
} else {
while (isAlive()) {
long delay = millis - now;
if (delay <= 0) {
break;
}
wait(delay);
now = System.currentTimeMillis() - base;
}
}
}
yield 释放CPU资源
yield是Thread类中的静态方法,将运行态的线程转到代运行态去,让出CPU资源,不保证能不能让出去。
为了解决某一个线程老是占着CPU,实际咋运行调度,不知道,这是操作系统该干的事,咱们管不着。
yield()从未导致线程转到等待/睡眠/阻塞状态。在大多数情况下,yield()将导致线程从运行状态转到可运行状态,但有可能没有效果。
yield()应该做的是让当前运行线程回到可运行状态,以允许具有相同优先级的其他线程获得运行机会。因此,使用yield()的目的是让相同优先级的线程之间能适当的轮转执行。但是,实际中无法保证yield()达到让步目的,因为让步的线程还有可能被线程调度程序再次选中。
对比
sleep | wait | join | yield | |
---|---|---|---|---|
同步 | 啥地方都能用 | 只能在同步上下文中调用否则会报错 | 同wait | 线程中间 |
作用对象 | 当前对象 | 当前对象 | 调用者 | 当前对象 |
是否释放锁资源 | 否 | 是 | 是 | 否 |
唤醒条件 | 超时或被其他线程调用对象的interrupt() | 超时或被其他线程调用对象的notify()、notifyAll() | 不知道呀 | 无需 |
方法属性 | 类的静态方法 | 实例方法 | 实例方法 | 实例方法 |
3.3 多线程调度机制
假设计算机只有一个CPU,在任意时刻只能执行一条机器指令,每个线程只有获得CPU的使用权才能执行指令
线程调度方式 | 协同式线程调度 | 抢占式线程调度 |
---|---|---|
描述 | 线程的执行时间有线程本身控制,执行完毕后主动通知操作系统切换到另一个线程 | 每个线程由操作系统来分配执行时间,线程的切换不由线程自身决定 |
缺点 | 某个线程如果不让出CPU资源,他会一致执行,一直站着CPU资源,系统可能会崩 | 实现相对复杂,操作系统需要控制线程同步和切换 |
优点 | 实现简单,没有线程同步问题 | 不会出现一个线程阻塞导致系统崩溃的问题 |
所有的Java虚拟机都有一个线程调度器,用来确定那个时刻运行那个线程。主要有两种调度模型:分时调度模型和抢占式调度模型。操作系统中CPU竞争有很多种策略,其中Unix系统使用的是时间片算法,而Windows则属于抢占式的。
协同分时调度模型是让所有线程轮流获取CPU的使用权,并且平均分配每个线程占用CPU的时间片。
Java虚拟机的线程调度机制是基于时间片轮转法的抢占式调度机制;是指优先让可运行池中处于就绪态的线程中优先级高的占用CPU,如果可运行池中线程的优先级相同,那么就随机选择一个线程,使其占用CPU,处于运行状态的线程会一直执行,直至它不得不放弃CPU
线程切换需要将线程中的数据进行存储和读取,这都是需要时间空间成本的,操作系统的选择会优先偏向于同优先级中刚执行完的那个线程
线程的调度不仅依赖于Java虚拟机,还要依赖于操作系统,Java只能告诉操作系统,你应该怎么干,但是具体怎么实现,还要具体系统具体分析。
4. 线程锁
具有排他性:不让别人进
问题引入:售票系统
多个售票线程,在获取票数到售出票期间,售票线程显示的还是没有卖出的票数,但是在售出的时候会从总部在票数进行减一操作。
在售票进行操作的时候线程会尽可能的去同步共享的数据再进行操作。
有票判定 和 售票减一 中间存在真空期,而在这个真空期,票数已经变了,但是当前线程已经判定了有票(真有票不影响,但是在没票的时候会出现负票的可能)。
一个步骤应该和另一个步骤同步进行,当这两个步骤中间的时间拉大的时候(就跟买火车票一样,你看着还有好多张,但是当你马不停蹄的去支付的时候,人家卖完了,当然这中间不会出现负数票数的时候,当然就是锁的功劳,他在第一时刻保证你票数)
在进入锁的代码块的时候,共享数据会被重读
//三个售票窗口同时出售20张票
public class Test1 {
public static void main(String[] args) {
for (int i = 1; i <= 3; i++) {
new 售票线程(i + "号窗口").start();
}
}
}
class 售票线程 extends Thread {
private String name;
private static Integer count = 2;// 票池,static保证多个线程对象共享一个票池
private static final String aaa = "bbbb";
public 售票线程(String name) {
this.name = name;
}
@Override
public void run() {
while (count > 0) {
System.out.println(this.name + count + "开始售票");
try {
sleep(100);// 模拟售票过程,加剧出错的可能性
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized (aaa) {
if (count > 0) {
System.out.println(this.name + "售出第" + count + "号票");
count--;
// count=cc-1;
System.out.println(this.name + ":" + count);
} else {
System.out.println(this.name + ":" +"票已经售尽!");
}
System.out.println(this.name + count + "结束售票");
}
}
}
}
执行结果描述:当每加锁前,票数会被运行到负数,加了之后就不会了,在进入锁时,线程会从主内存中重新读取数据用于保证锁内的数据是有效的。
保证
- 可见:这个线程对主内存数据的操作对于其他线程是透明的
- 原子:操作无法分割,要么执行要么不执行。
- 串行:原来并发或并行的程序,这里都变成串行。
降低执行效率:所以使用synchronized关键字会降低程序的运行效率。
- synchronized将并行改为串行,当然会影响程序的执行效率,执行速度会受到影响。
- 其次synchronized操作线程的堵塞,也就是由操作系统控制CPU的内核进行上下文的切换,这个切换本身也是耗时的。
扩展小芝士
- 一般情况下日期类型出现了long类型基本就是毫秒为单位,int类型基本就是以秒为单位,
Date类的部分方法
//Date和DateFormat线程不安全 // 针对日期进行格式化处理 date---string string--date DateFormat df = DateFormat.getDateInstance(); Date now = new Date(); // LocalDate //Date()系统当前时 Date(年-1900,月-1,日) Date(年-1900,月-1,日,时,分,秒) //Date(long) 参数为时间戳 单位ms //按照指定的格式,将日期类型数据转换为字符串 String ss=df.format(now); System.out.println(ss); //2022年8月17日 df=DateFormat.getTimeInstance(); System.out.println(df.format(now)); //下午2:49:53 df=DateFormat.getDateTimeInstance(); System.out.println(df.format(now)); //2022年8月17日 下午2:50:22 //以上使用的是系统预定义格式,如果需要自定义格式可以使用SimpleDateFormat子类 //yyyy年份,MM月份,dd日期,E星期,HH小时mm分钟ss秒 df=new SimpleDateFormat("yyyy-MM-ddE HH:mm:ss"); System.out.println(df.format(now)); //2022-08-17周三 14:53:09 //将字符串转换为日期类型,如果格式不正确则报异常 String s1="2022-08-17周三 14:53:09"; Date nn=df.parse(s1); //将字符串转换为日期类型 System.out.println(nn); //Wed Aug 17 14:53:09 CST 2022
- 时间管理大师
暂停线程
suspend()方法用于暂停线程的执行,该方法容易导致死锁,因为该线程在暂停的时候仍然占有该资源,这会导致其他需要该资源的线程与该线程产生环路等待,从而造成死锁
恢复线程
resume()方法用于恢复线程的执行。
suspend() 和 resume() 方法:两个方法配套使用,suspend()使得线程进入阻塞状态,并且不会自动恢复,必须其对应的 resume() 被调用,才能使得线程重新进入可执行状态
Java提供的一些线程的检查工具
如果项目在生产环境中运行,不可能频繁调用Thread#getState()方法去监测线程的状态变化。JDK本身提供了一些监控线程状态的工具,还有一些开源的轻量级工具如阿里的Arthas
- jstack
jstack是JDK自带的命令行工具,功能是用于获取指定PID的Java进程的线程栈信息。
例如本地运行的一个IDEA实例的PID是11376,那么只需要输入:jstack 11376