并发编程
1. 并发基础
Java从诞生开始,其就已经内置了对于多线程的支持。当多个线程能够同时执行时,大多数情况下都能够显著提升系统性能,尤其现在的计算机普遍都是多核的,所以性能的提升会更加明显。但是,多线程在使用中也需要注意诸多的问题,如果使用不当,也会对系统性能造成非常严重的影响。
1.1 并发编程核心概念
要理解并发编程,务必要先理解三个概念,分别为:原子性、可见性、有序性。
2.1.1 原子性
所谓原子性即:一个或者多个操作,要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行。
在原子操作中,本质上拒绝多线程操作的,不论是单核或多核服务器,当要对某一个数据进行原子操作时,同一时刻只有有一个线程能够对其进行操作,简单来说,在整个操作过程中不会被线程调度器打断,如a=1就是一个原子操作,但a++则不是一个原子操作,因为其内部会额外产生一个新的Integer对象。
举个例子,假设对一个32位的变量赋值,操作分为两步:低16位赋值、高16位赋值。当线程A对低16位数据写入成功后,线程A被中断。而此时另外的线程B去读取a的值,那么读取到的就是错误的数据。
在Java中的原子性操作包括:
- 基本类型的读取和赋值操作,且赋值必须是数字赋值给变量,变量之间的相互赋值不是原子性操作。
- 所有引用的赋值操作。
- java.concurrent.Atomic.* 包中所有原子操作类的一切操作。
2.1.2 可见性
所谓可见性:即当多个线程访问同一个共享变量时,一个线程修改了该共享变量的值后,其他线程能够立即查看到修改后的值。
在多线程环境下,一个线程对共享变量的操作对其他线程是默认是不可见的,也就是说一个线程对某一共享的修改,默认其他线程是无法进行查看的。而如果要做到可见,Java中的volatile、synchronized、Lock都能保证可见性。如一个变量被volatile修饰后,表示当一个线程修改共享变量后,其会立即被更新到主内存中,其他线程读取共享变量时,会直接从主内存中读取。而synchronized和Lock能保证同一时刻只有一个线程获取锁然后执行同步代码,并且在释放锁之前会将对变量的修改刷新到主存当中。因此可以保证可见性。
public class VolatileTest {
static int a=1;
public static void main(String[] args) throws InterruptedException {
new Thread(new Runnable() {
@Override
public void run() {
System.out.println(Thread.currentThread().getName()+"线程启动了");
while(true) {
//当a=3 跳出死循环
if(a==3) {
break;
}
}
System.out.println(Thread.currentThread().getName()+"线程停止了");
}
},"t2").start();
new Thread(new Runnable() {
@Override
public void run() {
System.out.println(Thread.currentThread().getName()+"线程启动了");
while(true) {
//当a=3 跳出死循环
if(a==3) {
break;
}
}
System.out.println(Thread.currentThread().getName()+"线程停止了");
}
},"t1").start();
Thread.sleep(1000);
//在主线程里边修改变量,测试其他线程是否对这个修改可见
a=3;
}
}
通过上述程序运行效果可以发现,对于变量a如果没有加volatile关键字,则线程1和线程2会进入死循环,因为在主线程中修改变量a,线程1和线程2是无法感知的。而当对变量a添加了volatile关键字后,则不会死循环,因为其已经可以保证线程可见性。
2.1.3 有序性
所谓有序性:即程序执行的顺序会按照代码的先后顺序执行。
其可以理解为在本线程内,所有的操作都是有序的。而如果在A线程中观察B线程,所有的操作都是无序的。在JMM中为了提升程序的执行效率,允许编译器和处理器对指令重排序。对于单线程来说,指令重排并不会产生问题,而在多线程下则不可以。
在Java中可以通过synchronized和Lock来保证有序性,synchronized和Lock保证每个时刻是有一个线程执行同步代码,相当于是让线程顺序执行同步代码,自然就保证了有序性。
另外还可以通过volatile来保证一定的有序性。最著名的例子就是单例模式的DCL(双重检查锁)。
public class Singleton {
private volatile static Singleton instance = null;
public static Singleton getInstance() {
if(null == instance) {
synchronized (Singleton.class) {
if(null == instance) {
instance = new Singleton();
}
}
}
return instance;
}
}
对于并发编程来说,要想保证程序的正确执行,对于原子性、可见性、有序性的保证是非常重要的!!!
1.2 进程、线程
1.2.1 什么是进程
进程可以理解为就是应用程序的启动实例。如微信、Idea、Navicat等,当打开它们后,就相当于开启了一个进程。每个进程都会在操作系统中拥有独立的内存空间、地址、文件资源、数据资源等。进程是资源分配和管理的最小单位
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-k6S2rDxS-1687955915292)(assets/image-20200723204916791.png)]
1.2.2)什么是线程
线程从属于进程,是程序的实际执行者,一个进程中可以包含若干个线程,并且也可以把线程称为轻量级进程。每个线程都会拥有自己的计数器、堆栈、局部变量等属性,并且能够访问共享的内存变量。线程是操作系统(CPU)调度和执行的最小单位。CPU会在这些线程上来回切换,让使用者感觉线程是在同时执行的。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-TfqEFrzc-1687955915293)(assets/image-20200723204935791.png)]
1.2.3)线程使用带来的问题
有很多人都会存在一个误区,在代码中使用多线程,一定会为系统带来性能提升,这个观点是错误的。并发编程的目的是为了让程序运行的更快,但是,绝对不是说启动的线程越多,性能提升的就越大,其会受到很多因素的影响,如锁问题、线程状态切换问题、线程上下文切换问题,还会受到硬件资源的影响,如CPU核数。
1.2.3.1)什么叫做线程上下文切换
不管是在多核甚至单核处理器中,都是能够以多线程形式执行代码的,CPU通过给每个线程分配CPU时间片来实现线程执行间的快速切换。 所谓的时间片就是CPU分配给每个线程的执行时间,当某个线程获取到CPU时间片后,就会在一定时间内执行,当时间片到期,则该线程会进入到挂起等待状态。时间片一般为几十毫秒,通过在CPU的高速切换,让使用者感觉是在同时执行。
同时还要保证线程在切换的过程中,要记录线程被挂起时,已经执行了哪些指令、变量值是多少,那这点则是通过每个线程内部的程序计数器来保证。
简单来说:线程从挂起到再加载的过程,就是一次上下文切换。其是比较耗费资源的。
引起上下文切换的几种情况:
- 时间片用完,CPU正常调度下一个任务。
- 被其他优先级更高的任务抢占。
- 执行任务碰到IO阻塞,调度器挂起当前任务,切换执行下一个任务。
- 用户代码主动挂起当前任务让出CPU时间。
- 多任务抢占资源,由于没有抢到被挂起。
- 硬件中断。
1.3 CPU时间片轮转机制&优化
之前已经提到了线程的执行,是依赖于CPU给每个线程分配的时间来进行。在CPU时间片轮转机制中,如果一个线程的时间片到期,则CPU会挂起该线程并给另一个线程分配一定的时间分片。如果进程在时间片结束前阻塞或结束,则 CPU 会立即进行切换。
时间片太短会导致频繁的进程切换,降低了 CPU 效率: 而太长又可能引起对短的交互请求的响应变差。时间片为 100ms 通常是一个比较合理的折衷。
1.5 并行与并发的理解
对于这两个概念,如果刚看到的话,可能会很不屑。但是真的理解什么叫做并行,什么叫做并发吗?
所谓并发即让多个任务能够交替执行,一般都会附带一个时间单位,也就是所谓的在单位时间内的并发量有多少。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-SUrVIP9X-1687955915293)(assets/image-20200723205250316.png)]
所谓并行即让多个任务能够同时执行。比如说:你可以一遍上厕所,一遍吃饭。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-4mVBteaP-1687955915294)(assets/image-20200723205302865.png)]
1.6 线程启动&中止
线程的实现方式有两种:继承Thread类、实现Runnable接口。但是有一些书籍或者文章会说有三种方式,即实现Callable接口。但通过该接口定义线程并不是Java标准的定义方式,而是基于Future思想来完成。 Java官方说明中,已经明确指出,只有两种方式。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-kLWBq2ao-1687955915294)(assets/image-20200723205452047.png)]
那么Thread和Runnable有什么区别和联系呢? 一般来说,Thread是对一个线程的抽象,而Runnable是对业务逻辑的抽象,并且Thread 可以接受任意一个 Runnable 的实例并执行。
1.6.1 线程启动
/**
* 新建线程
*/
public class NewThread {
private static class UseThread extends Thread{
@Override
public void run() {
System.out.println(Thread.currentThread().getName()+": use thread");
}
}
private static class UseRunnable implements Runnable{
@Override
public void run() {
System.out.println(Thread.currentThread().getName()+": use runnable");
}
}
public static void main(String[] args) {
System.out.println(Thread.currentThread().getName()+": use main");
UseThread useThread = new UseThread();
useThread.start();
Thread thread = new Thread(new UseRunnable());
thread.start();
}
}
优化:启动线程前,最好为这个线程设置特定的线程名称,这样在出现问题时,给开发人员一些提示,快速定位到问题线程。
Thread.currentThread().setName("Runnable demo");
1.6.2 线程中止
线程在正常下当run执行完,或出现异常都会让该线程中止。
1.6.2.1 理解suspend()、resume()、stop()
这三个方法对应的是暂停、恢复和中止。对于这三个方法的使用效果演示如下:
public class Srs {
private static class MyThread implements Runnable{
@Override
public void run() {
DateFormat dateFormat = new SimpleDateFormat("HH:mm:ss");
while (true){
System.out.println(Thread.currentThread().getName()+"run at"+dateFormat.format(new Date()));
try {
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
public static void main(String[] args) throws InterruptedException {
Thread thread = new Thread(new MyThread());
//开启线程
System.out.println("开启线程");
thread.start();
TimeUnit.SECONDS.sleep(3);
//暂停线程
System.out.println("暂停线程");
thread.suspend();
TimeUnit.SECONDS.sleep(3);
//恢复线程
System.out.println("恢复线程");
thread.resume();
TimeUnit.SECONDS.sleep(3);
//中止线程
System.out.println("中止线程");
thread.stop();
}
}
执行结果
开启线程
my threadrun at22:42:24
my threadrun at22:42:25
my threadrun at22:42:26
暂停线程
恢复线程
my threadrun at22:42:30
my threadrun at22:42:31
my threadrun at22:42:32
中止线程
可以看到这三个方式,很好的完成了其本职工作。但是三个已经在Java源码中被标注为过期方法。那这三个方式为什么会被标记为过期方法呢?
当调用**suspend()**时,线程不会将当前持有的资源释放(如锁),而是占有者资源进入到暂停状态,这样的话,容易造成死锁问题的出现。
public class Srs {
private static Object obj = new Object();//作为一个锁
private static class MyThread implements Runnable{
@Override
public void run() {
synchronized (obj){
DateFormat dateFormat = new SimpleDateFormat("HH:mm:ss");
while (true){
System.out.println(Thread.currentThread().getName()+"run at"+dateFormat.format(new Date()));
try {
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
}
public static void main(String[] args) throws InterruptedException {
Thread thread = new Thread(new MyThread(),"正常线程");
Thread thread1 = new Thread(new MyThread(),"死锁线程");
//开启线程
thread.start();
TimeUnit.SECONDS.sleep(3);
//暂停线程
thread.suspend();
System.out.println("暂停线程");
thread1.start();
TimeUnit.SECONDS.sleep(3);
//恢复线程
/*thread.resume();
System.out.println("恢复线程");
TimeUnit.SECONDS.sleep(3);*/
//中止线程
/*thread.stop();
System.out.println("中止线程");*/
}
}
在上述代码中,正常线程持有了锁,当调用**suspend()**时,因为该方法不会释放锁,所以死锁线程因为获取不到锁而导致无法执行。
正常线程run at23:37:28
正常线程run at23:37:29
正常线程run at23:37:30
暂停线程
当调用stop()时,会立即停止run()中剩余的操作。因此可能会导致一些的工作得不到完成,如文件流,数据库等关闭。并且会立即释放该线程所持有的所有的锁,导致数据得不到同步的处理,出现数据不一致的问题。
public class StopProblem {
public static void main(String[] args) throws Exception {
TestObject testObject = new TestObject();
Thread t1 = new Thread(() -> {
try {
testObject.print("1", "2");
} catch (Exception e) {
e.printStackTrace();
}
});
t1.start();
//让子线程有执行时间
Thread.sleep(1000);
t1.stop();
System.out.println("first : " + testObject.getFirst() + " " + "second : " + testObject.getSecond());
}
}
class TestObject {
private String first = "ja";
private String second = "va";
public synchronized void print(String first, String second) throws Exception {
System.out.println(Thread.currentThread().getName());
this.first = first;
//2.模拟数据不一致
//TimeUnit.SECONDS.sleep(3);
this.second = second;
}
public String getFirst() {
return first;
}
public String getSecond() {
return second;
}
}
1.6.2.2 线程中止的安全且优雅姿势
Java对于线程安全中止设计了一个中断属性,其可以理解是线程的一个标识位属性。它用于表示一个运行中的线程是否被其他线程进行了中断操作。好比其他线程对这个线程打了一个招呼,告诉它你该中断了。通过**interrupt()**实现。
public class InterruptDemo {
private static class MyThread implements Runnable{
@Override
public void run() {
while (true){
System.out.println(Thread.currentThread().getName()+" is running");
}
}
}
public static void main(String[] args) throws InterruptedException {
Thread thread = new Thread(new MyThread(),"myThread");
thread.start();
TimeUnit.SECONDS.sleep(3);
thread.interrupt();
}
}
添加该方法后,会出现一个异常,但是可以发现并不会线程的继续执行。
线程通过检查自身是否被中断来进行响应,可以通过**isInterrupted()**进行判断,如果返回值为true,代表添加了中断标识,返回false,代表没有添加中断标识。通过它可以对线程进行中断操作。
public class InterruptDemo {
private static class MyThread implements Runnable{
@Override
public void run() {
//while (true){
while (!Thread.currentThread().isInterrupted()){
System.out.println(Thread.currentThread().getName()+" is running");
}
System.out.println(Thread.currentThread().getName()+" Interrupt flag is : "+Thread.currentThread().isInterrupted());
}
}
public static void main(String[] args) throws InterruptedException {
Thread thread = new Thread(new MyThread(),"myThread");
thread.start();
TimeUnit.SECONDS.sleep(3);
thread.interrupt();
}
}
对线程中断属性的判断,可以利用其进行线程执行的中断操作。
线程也可以通过静态方法**Thread.interrupted()**查询线程是否被中断,并对中断标识进行复位,如果该线程已经被添加了中断标识,当使用了该方法后,会将线程的中断标识由true改为false。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-PWzTW6eU-1687955915296)(assets/image-20200727142422111.png)]
public class InterruptDemo {
private static class MyThread implements Runnable{
@Override
public void run() {
//while (true){
//while (!Thread.currentThread().isInterrupted()){
while (!Thread.interrupted()){
System.out.println(Thread.currentThread().getName()+" is running");
}
System.out.println(Thread.currentThread().getName()+" Interrupt flag is : "+Thread.currentThread().isInterrupted());
}
}
public static void main(String[] args) throws InterruptedException {
Thread thread = new Thread(new MyThread(),"myThread");
thread.start();
TimeUnit.SECONDS.sleep(3);
thread.interrupt();
}
}
同时要注意:处于死锁下的线程,无法被中断
1.7 深入线程操作常见方法
1.7.1 理解run()&start()
这两个方法都可以启动线程,但是它俩是有本质上的区别的。当线程执行了 start()方法后,才真正意义上的启动线程,其会让一个线程进入就绪状态等待分配CPU时间片,分到时间片后才会调用run()。注意,同一个线程的start()不能被重复调用,否则会出现异常,因为重复调用了,start方法,线程的state就不是new了,那么threadStatus就不等于0了。
//start源码分析
public synchronized void start() {
/**
Java里面创建线程之后,必须要调用start方法才能创建一个线程,该方法会通过虚拟机启动一个本地线程,本地线程的创建会调用当前系统去创建线程的方法进行创建线程。
最终会调用run()将线程真正执行起来
0这个状态,等于‘New’这个状态。
*/
if (threadStatus != 0)
throw new IllegalThreadStateException();
/* 线程会加入到线程分组,然后执行start0() */
group.add(this);
boolean started = false;
try {
start0();
started = true;
} finally {
try {
if (!started) {
group.threadStartFailed(this);
}
} catch (Throwable ignore) {
/* do nothing. If start0 threw a Throwable then
it will be passed up the call stack */
}
}
}
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-KQLhv1YX-1687955915296)(assets/image-20200723210331930.png)]
而run()则仅仅是一个普通方法,与类中的成员方法意义相同。在该方法中可以实现线程执行的业务逻辑。但并不会以异步的方式将线程启动,换句话说就是并不会去开启一个新的线程。其可以单独执行,也可以重复执行。
1.7.3 wait()、notify()
wait()、notify()、notifyAll()是三个定义在Object类里的方法,可以用来控制线程的状态。
注意:一定要在线程同步中使用,并且是同一个锁的资源
wait和notify方法例子,打开关闭开关:
public class WaitNotify {
static boolean flag = false;
static Object lock = new Object();
//创建等待线程
static class WaitThread implements Runnable{
@Override
public void run() {
synchronized (lock){
while (!flag){
//条件不满足,进入等待
System.out.println(Thread.currentThread().getName()+" flag is false,waiting");
try {
lock.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
//条件满足,退出等待
System.out.println(Thread.currentThread().getName()+" flag is true");
}
}
}
static class NotifyThread implements Runnable{
@Override
public void run() {
synchronized (lock){
System.out.println(Thread.currentThread().getName()+" hold lock");
lock.notify();
flag=true;
try {
TimeUnit.SECONDS.sleep(5);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
public static void main(String[] args) throws InterruptedException {
new Thread(new WaitThread(),"wait").start();
TimeUnit.SECONDS.sleep(1);
new Thread(new NotifyThread(),"notify").start();
}
}
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-MgcK07M0-1687955915297)(assets/image-20200727175738217.png)]
1)WaitThread首先获取对象锁。
2)WaitThread调用对象的wait()方法,放弃锁并进入对象的等待队列WaitQueue,进行等待状态。
3)由于WaitThread释放了对象锁,NotifyThread随机获取对象锁。
4)NotifyThread获取对象锁成功后,调用notify()或notifyAll(),将WaitThread从等待队列WaitQueue移到同步队列
SynchronizedQueue,此时WaitThread为阻塞状态。
5)NotifyThread释放锁后,WaitThread再次获取锁并从wait()方法继续执行。
1.7.3.1 等待通知范式
多线程的等待通知是一道非常常见的面试题,常见于笔试中。对于等待通知来说,需要有生产者(通知方)与消费者(等待方)
等待方:
- 获取对象锁。
- 如果条件不满足,那么调用对象的wait方法,被通知后仍要检查条件。
- 条件满足则执行对应逻辑。
synchronized(对象){
while(条件不满足){
对象.wait();
}
条件满足,执行业务逻辑。
}
通知方:
- 获取对象锁。
- 改变条件。
- 通知等待在该对象上的线程。
synchronized(对象){
改变条件
对象.notify()
}
1.7.4 wait与sleep区别
-
对于sleep()方法,首先要知道该方法是属于Thread类中的。而wait()方法,则是属于Object类中的。
-
sleep()方法导致了程序暂停执行指定的时间,让出cpu调度其他线程,但是他的监控状态依然保持者,当指定的时间到了又会自动恢复运行状态。
wait()是把控制权交出去,然后进入等待此对象的等待锁定池处于等待状态,只有针对此对象调用notify()方法后本线程才进入对象锁定池准备获取对象锁进入运行状态。
-
在调用sleep()方法的过程中,线程不会释放锁。而当调用wait()方法的时候,线程会释放锁。
1.7.5 理解yield()
当某个线程调用了这个方法后,该线程立即释放自己持有的时间片。线程会进入到就绪状态,同时CPU会重新选择一个线程赋予时间分片,但注意,调用了这个方法的线程,也有可能被CPU再次选中赋予执行。
而且该方法不会释放锁。 如需释放锁的话,可以在调用该方法前自己手动释放。
public class YieldDemo {
static class MyThread implements Runnable{
@Override
public void run() {
for (int i = 0; i < 10; i++) {
System.out.println(Thread.currentThread().getName()+" : "+i);
if (i == 5){
System.out.println(Thread.currentThread().getName());
Thread.yield();
}
}
}
}
public static void main(String[] args) {
new Thread(new MyThread()).start();
new Thread(new MyThread()).start();
new Thread(new MyThread()).start();
}
}
从结果看出,当调用了该方法后线程会让出自己的时间分片,但也有可能被再次选中执行。
Thread-3 0
Thread-1 0
Thread-5 0
Thread-5 1
Thread-5 2
Thread-5 3
Thread-5 4
Thread-5 5
Thread-5
Thread-1 1
Thread-1 2
Thread-1 3
Thread-1 4
Thread-1 5
Thread-1
Thread-1 6
Thread-1 7
Thread-1 8
Thread-1 9
Thread-3 1
Thread-3 2
Thread-3 3
Thread-3 4
Thread-3 5
Thread-3
Thread-5 6
Thread-5 7
Thread-5 8
Thread-5 9
Thread-3 6
Thread-3 7
Thread-3 8
Thread-3 9
1.7.6 理解join()
该方法的使用,在实际开发中,应用的是比较少的。但在面试中,常常伴随着产生一个问题,如何保证线程的执行顺序? 就可以通过该方法来设置。
1.7.6.1 使用
当线程调用了该方法后,线程状态会从就绪状态进入到运行状态。
public class JoinDemo {
private static class MyThread extends Thread{
int i;
Thread previousThread; //上一个线程
public MyThread(Thread previousThread,int i){
this.previousThread=previousThread;
this.i=i;
}
@Override
public void run() {
//调用上一个线程的join方法. 不使用join方法解决是不确定的
//previousThread.join();
System.out.println("num:"+i);
}
}
public static void main(String[] args) {
Thread previousThread=Thread.currentThread();
for(int i=0;i<10;i++){
//每一个线程实现都持有前一个线程的引用。
MyThread joinDemo=new MyThread(previousThread,i);
joinDemo.start();
previousThread=joinDemo;
}
}
}
num:0
num:2
num:3
num:6
num:7
num:1
num:5
num:9
num:8
num:4
可以等到开启了join之后,结果就是有序的了。
num:0
num:1
num:2
num:3
num:4
num:5
num:6
num:7
num:8
num:9
根据结果可以看到,当前线程需要等待previousThread线程终止之后才从thread.join返回。可以理解为,线程会在join处等待。
1.7.6.2 原理剖析
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");
}
//判断是否携带阻塞的超时时间,等于0表示没有设置超时时间
if (millis == 0) {
//isAlive获取线程状态,无限等待直到previousThread线程结束
while (isAlive()) {
//调用Object中的wait方法实现线程的阻塞
wait(0);
}
} else { //阻塞直到超时
while (isAlive()) {
long delay = millis - now;
if (delay <= 0) {
break;
}
wait(delay);
now = System.currentTimeMillis() - base;
}
}
}
可以看到该方法是被synchronized修饰的,因为在其内部对于线程阻塞的实现,是通过Object中wait方法实现的,而要调用wait(),则必须添加synchronized。
总的来说,Thread.join其实底层是通过wait/notifyall来实现线程的通信达到线程阻塞的目的;当线程执行结束以后,会触发两个事情,第一个是设置native线程对象为null、第二个是通过notifyall方法,让等待在previousThread对象锁上的wait方法被唤醒。
1.8 线程优先级
操作对于线程执行,是通过CPU时间片来调用运行的。那么一个线程被分配的时间片的多少,就决定了其使用资源的多少。而线程优先级就是决定线程需要能够使用资源多少的线程属性。
线程优先级的范围是1~10。一个线程的默认优先级是5,可以在构建线程时,通过**setPriority()**修改该线程的优先级。优先级高的线程分配时间片的数量会高于优先级低的线程。
一般来说对于频繁阻塞的线程需要设置优先级高点,而偏重计算的线程优先级会设置低些,确保处理器不会被独占。
但注意,线程优先级不能作为线程执行正确性的依赖,因为不同的操作系统可能会忽略优先级的设置。
1.9 守护线程
守护线程是一种支持型的线程,我们之前创建的线程都可以称之为用户线程。通过守护线程可以完成一些支持性的工作,如GC、分布式锁续期。守护线程会伴随着用户线程的结束而结束。
对于守护线程的创建,可以通过setDaemon()设置。
public class DaemonDemo {
private static class MyThread implements Runnable{
@Override
public void run() {
while (true){
System.out.println(Thread.currentThread().getName());
}
}
}
public static void main(String[] args) throws InterruptedException {
Thread thread = new Thread(new MyThread());
//设置守护线程
thread.setDaemon(true);
thread.start();
TimeUnit.SECONDS.sleep(2);
}
}
当线程实例没有被设置为守护线程时,该线程并不会随着主线程的结束而结束。但是当被设置为守护线程后,当主线程结束,该线程也会伴随着结束。同时守护线程不一定会执行finally代码块。所以当线程被设定为守护线程后,无法确保清理资源等操作一定会被执行。
1.10 线程状态
理解了上述方法后,再来看一下这些对于线程状态转换能起到什么样的影响。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-sTInujAL-1687955915298)(assets/image-20200723221434772.png)]
2. 深入synchronized
2.1 synchronized基本使用
对于线程来说,如果多个线程只是相互间单独执行的话,本身是没有太大意义的,一般来说,都是需要多个线程,相互间协作来进行工作的,这样使用,才会对系统带来实际的意义。
在Java中支持多个线程同时访问一个对象或对象中的成员变量,但是如果没有任何保护机制的话,就会出现数据不一致的情况,效果如下:
public class SyncTest {
private long count = 0;
public long getCount() {
return count;
}
public void setCount(long count) {
this.count = count;
}
public void incrCount(){
count++;
}
private static class MyThread extends Thread{
SyncTest syncTest;
public MyThread(SyncTest syncTest) {
this.syncTest = syncTest;
}
@Override
public void run() {
for (int i = 0; i < 10000; i++) {
syncTest.incrCount();
}
}
}
public static void main(String[] args) throws InterruptedException {
SyncTest syncTest = new SyncTest();
Thread thread1 = new Thread(new MyThread(syncTest));
Thread thread2 = new Thread(new MyThread(syncTest));
thread1.start();
thread2.start();
TimeUnit.SECONDS.sleep(1);
System.out.println(syncTest.getCount());
}
}
根据结果可以,预计结果值是两万,但是并没有达到期望的效果。
现在要解决这个问题的话,就可以使用锁来进行解决。synchronized可以修饰方法或者以同步块的形式来进行使用,它主要确保多个线程在同一个时刻,只能有一个线程处于方法或者同步块中,它保证了线程对变量访问的可见性和排他性,又称为内置锁机制。
简单来说就像吃饭一样很多人一起来抢,我可能抢不到,但是我加了把锁,我进去先锁上门,等我吃完了,打开门,其他的人才能进来,synchronized 就是java多线程中的那把锁。
对于synchronized,可以把其加在方法上或者类上,或者添加同步代码块。演示效果如下
//添加在方法上
public synchronized void incrCount1(){
count++;
}
//添加同步代码块
//需要声明一个锁对象
//作为锁对象
private Object object = new Object();
public void incrCount2(){
synchronized (object){
count++;
}
}
这两种方式都可以保证,当前操作是加锁的,这样的话,在多线程下,只有等到获取到锁的线程将锁释放掉后,下一个线程才能持有锁。通过这种机制,来保证数据的一致性。
这种操作方式也可以叫做对象锁。但是如果现在通过synchronized锁定的是不同的两个对象的话,还能够保证锁的有效吗? 这就一定不能了,因为多个线程持有的对象锁是不同的话,这样的话,是没有意义的,线程间仍然是可以并行执行的,使用效果如下:
public class DiffInstance {
private static class InstanceSyn implements Runnable{
private DiffInstance diffInstance;
public InstanceSyn(DiffInstance diffInstance) {
this.diffInstance = diffInstance;
}
@Override
public void run() {
System.out.println("TestInstance is running..."+ diffInstance);
diffInstance.instance();
}
}
private static class Instance2Syn implements Runnable{
private DiffInstance diffInstance;
public Instance2Syn(DiffInstance diffInstance) {
this.diffInstance = diffInstance;
}
@Override
public void run() {
System.out.println("TestInstance2 is running..."+ diffInstance);
diffInstance.instance2();
}
}
private synchronized void instance(){
try {
TimeUnit.SECONDS.sleep(3);
System.out.println("synInstance is going..."+this.toString());
TimeUnit.SECONDS.sleep(3);
System.out.println("synInstance ended "+this.toString());
} catch (InterruptedException e) {
e.printStackTrace();
}
}
private synchronized void instance2(){
try {
TimeUnit.SECONDS.sleep(3);
System.out.println("synInstance2 is going..."+this.toString());
TimeUnit.SECONDS.sleep(3);
System.out.println("synInstance2 ended "+this.toString());
} catch (InterruptedException e) {
e.printStackTrace();
}
}
public static void main(String[] args) throws InterruptedException {
DiffInstance instance1 = new DiffInstance();
Thread t3 = new Thread(new Instance2Syn(instance1));
DiffInstance instance2 = new DiffInstance();
Thread t4 = new Thread(new InstanceSyn(instance1));
//Thread t4 = new Thread(new InstanceSyn(instance2));
t3.start();
t4.start();
TimeUnit.SECONDS.sleep(1);
}
}
对于synchronized,也可以加载类上进行使用。此时可以把它称为类锁。此时加锁的就是一个class对象了。
但是有一点必须注意的是,其实类锁只是一个概念上的东西,并不是真实存在的,类锁其实锁的是每个类的对应的class对象。类锁和对象锁之间也是互不干扰的。
private static synchronized void synClass(){
System.out.println(Thread.currentThread().getName()
+"synClass going...");
SleepTools.second(1);
System.out.println(Thread.currentThread().getName()
+"synClass end");
}
在使用synchronized时,建议锁定的范围,越小越好。否则的话,容易造成大量资源被锁定。
2.2 synchronized错误加锁问题
public class ErrorSyncTest {
private static class MyThread implements Runnable{
private Integer i;
public MyThread(Integer i) {
this.i = i;
}
@Override
public void run() {
synchronized (i){
i++;
System.out.println(i);
try {
TimeUnit.MILLISECONDS.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
public static void main(String[] args) {
MyThread myThread = new MyThread(1);
for (int i = 0; i < 10; i++) {
new Thread(myThread).start();
}
}
}
根据上述效果可以看到,在多线程执行时,虽然添加了锁,但是仍然不能保证数据的正确性,这是因为什么呢? 这里主要是因为在执行i++时,其内部是重新新建了一个对象。导致锁定对象出现了不一致。只要让它保证是锁定的同一个对象即可。
2.3 synchronized实现原理
很多人对于synchronized的理解都会认为它是一把重量级锁(操作需要经过操作系统)。但是从Java1.6后,已经对synchronized进行了优化改良,在一些情况下它就已经没有那么重了。
2.3.1 实现原理解析
当一个线程访问同步代码块时,它首先需要先获取到锁才能执行,退出或抛出异常时必须释放锁,那么其内部又是如何实现的呢?
public class SynchronizedTest {
public void test2() {
synchronized (this) {
}
}
}
利用javap工具查看生成的class文件信息来分析Synchronize的实现
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-JWgRYGdy-1687955915299)(assets/image-20200723223047596.png)]
从上述内容可以看出,同步代码块是使用monitorenter和monitorexit指令实现的。而同步方法是使用另外一种方式实现的,细节在JVM规范里没有详细说明。但是可以肯定的是,同步方法可以使用这两个指令来实现。
2.3.2 锁到底存在哪里&锁里面会存储什么信息
synchronized用的锁会保存在Java对象头中。那什么是Java对象头呢?
一个Java对象在内存中是由三部分组成的:
- 对象头
- 实例数据
- 对齐填充字节
如果对象不是数组类型,则JVM用2字宽存储对象头。如果是数组类型,则用3字宽存储对象头。在32位虚拟机中,1字宽等于4字节,即32bit。在64位虚拟机中,1字宽相当于8字节,即64bit。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-wasdAhIn-1687955915300)(assets/image-20200723223117879.png)]
而对象头也是由三部分组成:
- MarkWord
- 类型指针
- 数组长度(只有数组对象有)
Mark Word用于存储对象自身的运行时数据,如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程 ID、偏向时间戳等等。Java对象头一般占有两个机器码(在32位虚拟机中,1个机器码等于4字节,也就是32bit)。 [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-rqSnBIpV-1687955915301)(assets/image-20200727234824553.png)]
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-jIHdSo8u-1687955915301)(assets/image-20200727232519217.png)]
2.3.3 Synchronized锁优化
Java1.6后为了减少获得锁和释放锁而带来的性能消耗,引入了偏向锁和轻量级锁。此时锁会存在四种状态,级别从低到高分别为:无锁、偏向锁、轻量级锁、重量级锁。状态间的转换会随着竞争情况逐渐升级。锁可以升级但不能降级,如偏向锁升级为轻量级锁后不能降级为偏向锁。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-hxcQtZHq-1687955915302)(assets/image-20200723223154992.png)]
2.3.3.1 自旋锁
线程的阻塞和唤醒需要CPU从用户态转为内核态,频繁的阻塞和唤醒对CPU来说是一件负担很重的工作,势必会给系统的并发性能带来很大的压力。同时我们发现在许多应用上面,对象锁的锁状态只会持续很短一段时间,为了这一段很短的时间频繁地阻塞和唤醒线程是非常不值得的。所以引入自旋锁。
所谓自旋锁,就是让该线程等待一段时间,不会被立即挂起,看持有锁的线程是否会很快释放锁。怎么等待呢?执行一段无意义的循环即可(自旋)。
自旋等待不能替代阻塞,先不说对处理器数量的要求(多核,貌似现在没有单核的处理器了),虽然它可以避免线程切换带来的开销,但是它占用了处理器的时间。如果持有锁的线程很快就释放了锁,那么自旋的效率就非常好,反之,自旋的线程就会白白消耗掉处理的资源,它不会做任何有意义的工作,典型的占着茅坑不拉屎,这样反而会带来性能上的浪费。所以说,自旋等待的时间(自旋的次数)必须要有一个限度,如果自旋超过了定义的时间仍然没有获取到锁,则应该被挂起。
自旋锁在JDK1.4中引入,默认关闭,但是可以使用**-XX:+UseSpinning开启,在JDK1.6中默认开启。同时自旋的默认次数为10次,可以通过参数-XX:PreBlockSpin**来调整;
如果通过参数**-XX:preBlockSpin**来调整自旋锁的自旋次数,会带来诸多不便。假如我将参数调整为10,但是系统很多线程都是等你刚刚退出的时候就释放了锁(假如你多自旋一两次就可以获取锁),你是不是很尴尬。于是JDK1.6引入自适应的自旋锁,让虚拟机会变得越来越聪明。
2.3.3.2 自适应自旋锁
JDK1.6引入了更加聪明的自旋锁,即自适应自旋锁。所谓自适应就意味着自旋的次数不再是固定的,它是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。它怎么做呢?线程如果自旋成功了,那么下次自旋的次数会更加多,因为虚拟机认为既然上次成功了,那么此次自旋也很有可能会再次成功,那么它就会允许自旋等待持续的次数更多。反之,如果对于某个锁,很少有自旋能够成功的,那么在以后要或者这个锁的时候自旋的次数会减少甚至省略掉自旋过程,以免浪费处理器资源。
有了自适应自旋锁,随着程序运行和性能监控信息的不断完善,虚拟机对程序锁的状况预测会越来越准确,虚拟机会变得越来越聪明。
2.3.3.3 锁消除
为了保证数据的完整性,我们在进行操作时需要对这部分操作进行同步控制,但是在有些情况下,JVM检测到不可能存在共享数据竞争,这时JVM会对这些同步锁进行锁消除。
锁消除发生在编译阶段,通过对运行上下文的扫描,去除不可能存在共享资源竞争的锁,通过锁消除,可以节省毫无意义的请求锁时间。
public class TestLockEliminate {
public static String getString(String s1, String s2) {
StringBuffer sb = new StringBuffer();
sb.append(s1);
sb.append(s2);
return sb.toString();
}
public static void main(String[] args) throws InterruptedException {
long tsStart = System.currentTimeMillis();
for (int i = 0; i < 10000000; i++) {
getString("TestLockEliminate ", "Suffix");
}
System.out.println("一共耗费:" + (System.currentTimeMillis() - tsStart) + " ms");
}
}
已上述代码为例,运行时间大概在500毫秒。那么在运行时,其内部又发生了什么呢?可以看到当前getString()中的StringBuffer是作为方法内部的局部变量,因此它不可能被多个线程同时访问,也就没有资源竞争,但是StringBuffer的append操作却需要执行同步操作:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-GHXwf4z3-1687955915303)(assets/image-20200728143709230.png)]
那么此时的同步锁相当于就是白白浪费系统资源。因此在编译时一旦JVM发现此种情况就会通过锁消除方式来优化性能。在JDK1.8中锁消除是自动开启的。
在循环中,让其每次都睡一秒,然后查看当前程序的JVM参数。DoEscapeAnalysis,EliminateLocks
.可以发现逃逸分析和锁消除是默认开启的。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-0K8nwyHd-1687955915303)(assets/image-20200728144049526.png)]
在程序执行时,关闭逃逸分析和锁消除。-server -XX:-DoEscapeAnalysis -XX:-EliminateLocks
。此时可以发现程序执行时间延长了很多。
2.3.3.4 锁升级过程详解
2.3.3.4.1 偏向锁
1)加锁
- 当线程初次执行到synchronized代码块时,会通过自旋方式修改MarkWord的锁标志,代表锁对象为偏向锁。
- 执行完同步代码块后,线程并不会主动释放偏向锁。
- 当第二次执行同步代码块时,首先会判断MarkWord中的线程ID是否为当前线程。
- 如果是,则正常往下执行同步代码块。由于之前没有释放锁,这里也就不需要重新加锁。如果自始至终使用锁的线程只有一个,很明显偏向锁几乎没有额外开销,性能极高。
- 如果线程ID并未指向当前线程,则通过CAS操作替换MarkWork中的线程ID。如果替换成功,则执行同步代码块;如果替换失败,执行步骤6。
- 如果CAS替换失败,则表示有竞争。当到达全局安全点(safepoint)时获得偏向锁的线程被挂起,偏向锁升级为轻量级锁,然后被阻塞在安全点的线程继续往下执行同步代码。(撤销偏向锁的时候会导致stop the word)
2)撤销
偏向锁的撤销在上述第六步中有提到。偏向锁只有遇到其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁,线程不会主动去释放偏向锁。偏向锁的撤销,需要等待全局安全点(在这个时间点上没有字节码正在执行),它会首先暂停拥有偏向锁的线程,判断锁对象是否处于被锁定状态,撤销偏向锁后恢复到未锁定(标志位为“01”)或轻量级锁(标志位为“00”)的状态。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-GO5QBXQo-1687955915304)(assets/image-20200723223236539.png)]
3)使用场景
始终只有一个线程在执行同步块,在它没有执行完释放锁之前,没有其它线程去执行同步块,在锁无竞争的情况下使用,一旦有了竞争就升级为轻量级锁,升级为轻量级锁的时候需要撤销偏向锁,撤销偏向锁的时候会导致stop the word操作;
在有锁竞争时,偏向锁会多做很多额外操作,尤其是撤销偏向所的时候会导致进入安全点,安全点会导致stw,导致性能下降;
2.3.3.4.2 轻量级锁
轻量级锁是由偏向锁升级来的,偏向锁运行在一个线程进入同步块的情况下,当第二个线程加入锁争用的时候,偏向锁就会升级为轻量级锁;
1)加锁
- 在进入同步代码块前,JVM会在当前线程的栈帧中创建用于存储锁记录的空间,并将对象头中的MarkWord复制到锁记录中。
- 然后线程尝试使用自旋将对象头中的MarkWord替换为指向锁记录的指针。
- 如果成功,当前线程获得轻量级锁,执行同步代码块。
- 如果失败,表示其他线程竞争锁,当前线程便尝试使用自旋来获取锁。当自旋次数达到一定次数时,锁就会升级为重量级锁,并阻塞线程。
2)解锁
解锁时,会使用自旋操作将锁记录替换回到对象头,相当于做一个对比。如果成功,表示没有竞争发生;如果失败,表示当前锁存在竞争,锁已经被升级为重量级锁,会释放锁并唤醒等待的线程。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-Zz9yxAoV-1687955915304)(assets/image-20200723223256861.png)]
因为自旋操作是依赖于CPU的,为了避免无用的自旋操作(获得锁的线程被阻塞住了),轻量级锁一旦升级为重量级锁,就不会再恢复到轻量级锁。当锁处于重量级锁时,其他线程试图获取锁时,都会被阻塞住。当持有锁的线程释放锁之后会唤醒其他线程再次开始竞争锁。
2.3.3.4.3 重量级锁
重量级锁通过对象内部的监视器(monitor)实现,其中monitor的本质是依赖于底层操作系统的Mutex Lock实现,操作系统实现线程之间的切换需要从用户态到内核态的切换,切换成本非常高。
切换成本高的原因在于,当系统检查到是重量级锁之后,会把等待想要获取锁的线程阻塞,被阻塞的线程不会消耗CPU,但是阻塞或者唤醒一个线程,都需要通过操作系统来实现,也就是相当于从用户态转化到内核态,而转化状态是需要消耗时间的 。
简单来说就是:竞争失败后,线程阻塞,释放锁后,唤醒阻塞的线程,不使用自旋锁,不会那么消耗CPU,所以重量级锁适合用在同步块执行时间长的情况下。
2.3.3.4.4 锁的优缺点对比
锁 | 优点 | 缺点 | 使用场景 |
---|---|---|---|
偏向锁 | 加锁和解锁会存在CAS,没有额外的性能消耗,和执行非同步方法相比,仅存在纳秒级的差距 | 如果线程间存在锁竞争,会带来额外的锁撤销的消耗 | 只有一个线程访问同步块或者同步方法的场景 |
轻量级锁 | 竞争的线程不会阻塞,提高程序响应速度 | 若线程长时间抢不到锁,自旋会消耗CPU性能 | 追求响应时间。同步代码块执行非常快 |
重量级锁 | 线程竞争不使用自旋,不消耗CPU | 线程阻塞,响应时间缓慢,在多线程下,频繁的获取释放锁,会带来巨大的性能消耗 | 追求吞吐量,同步块或者同步方法执行时间较长的场景 |
2.3.3.5.5 小结
偏向锁:在不存在多线程竞争情况下,默认会开启偏向锁。
偏向锁升级轻量级锁:当一个对象持有偏向锁,一旦第二个线程访问这个对象,如果产生竞争,偏向锁升级为轻量级锁。
轻量级锁升级重量级锁:一般两个线程对于同一个锁的操作都会错开,或者说稍微等待一下(自旋),另一个线程就会释放锁。但是当自旋超过一定的次数,或者一个线程在持有锁,一个在自旋,又有第三个来访时,轻量级锁膨胀为重量级锁,重量级锁使除了拥有锁的线程以外的线程都阻塞,防止CPU空转。
2.4 死锁
2.4.1 什么是死锁
在多线程环境中,多个线程可以竞争有限数量的资源。当一个线程申请资源时,如果这时没有可用资源,那么这个线程进入等待状态。如果所申请的资源被其他等待线程占有,那么该等待线程有可能再也无法改变状态。这种情况称为死锁
在Java中使用多线程,就会有可能导致死锁问题。死锁会让程序一直卡住,不再程序往下执行。我们只能通过中止并重启的方式来让程序重新执行。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-JgVVGi74-1687955915305)(assets/image-20200723230905234.png)]
2.4.2 造成死锁的原因
- 当前线程拥有其他线程需要的资源
- 当前线程等待其他线程已拥有的资源
- 都不放弃自己拥有的资源
2.4.3)死锁演示
所谓死锁是指多个线程因竞争资源而造成的一种僵局(互相等待),若无外力作用,这些进程都将无法向前推进,两个或两个以上的线程在执行过程中,因争夺资源而造成的一种互相等待的现象,若无外力作用,它们都将无法推进下去。
public class DeadLock {
private static String value1 = "a";
private static String value2 = "b";
public static void main(String[] args) {
new DeadLock().deadLock();
}
private void deadLock() {
Thread t1 = new Thread(()->{
try {
System.out.println("t1 running");
while (true){
synchronized (value1){
System.out.println("t1 lock value1");
Thread.sleep(3000);//获取obj1后先等一会儿,让Lock2有足够的时间锁住obj2
synchronized(value2){
System.out.println("t1 lock value2");
}
}
}
}catch (Exception e){
e.printStackTrace();
}
});
Thread t2 = new Thread(()->{
try {
System.out.println("t2 running");
while (true){
synchronized (value2){
System.out.println("t2 lock value2");
Thread.sleep(3000);//获取obj1后先等一会儿,让Lock2有足够的时间锁住obj2
synchronized(value1){
System.out.println("t2 lock value1");
}
}
}
}catch (Exception e){
e.printStackTrace();
}
});
t1.start();
t2.start();
}
}
这段只是用于演示死锁的效果,在实际开发中你可能不会写出这样的代码,但是,在一些复杂场景下,稍有不慎就会出现这样的问题。比方说t1拿到锁后,因为一些异常情况没有释放锁(死循环)。又或者t1释放锁时出现异常,没有释放到。 这些情况下都有可能出现死锁的问题。
2.4.4 死锁排查
在java程序中,死锁的出现有时是你根本无法想到的,如果不了解排查死锁的方法,就会造成线上系统性能的大幅度下降。那对于死锁应该如何排查呢?
2.4.4.1 通过JDK工具jps+jstack
jps是jdk提供的一个工具,可以查看到正在运行的java进程
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-Fhjq5bHS-1687955915306)(assets/image-20200723233955136.png)]
jstack也是jdk提供的工具,可以查看java进程中线程堆栈信息。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-nF8M8btq-1687955915306)(assets/image-20200723234108779.png)]
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-YHICYpUq-1687955915307)(assets/image-20200723234122006.png)]
从输出的堆栈信息中可以发现:Found one Java-level deadLock。表示在这个程序中发现了死锁,后面的详细描述中已经指出了在22行和39行出现死锁。 那就可以根据这些信息快速定位到问题点进行优化处理。
2.4.4.2 通过JDK工具jconsole
jconsole是JDK提供的一款可视化工具,可以更加方便的排查程序问题,如:内存溢出、死锁。其位于JDK的bin目录中
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-XxeSl1mm-1687955915307)(assets/image-20200723235323514.png)]
在选择好对应程序,点击连接即可。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-fbvBZVJe-1687955915307)(assets/image-20200723235421257.png)]
在该页面那种展示了程序运行的相关详情信息。此时可以选择线程,来查看线程在堆栈中的详细信息。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-TJ4jhkAE-1687955915309)(assets/image-20200723235525526.png)]
点击检测死锁,即可看到程序中的死锁信息
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-s4UUdAQT-1687955915310)(assets/image-20200723235604118.png)]
2.4.4.3 通过JDK工具VisualVM
其也是JDK提供的一款非常强大的程序问题检测工具,可以监控程序性能、查看JVM配置信息、堆栈信息。位于JDK的bin目录中(jvisualvm.exe)。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-aUTeihW4-1687955915310)(assets/image-20200723235907826.png)]
在左侧列表中,打开你当前要来查看的线程。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-uEfNrVXu-1687955915311)(assets/image-20200723235953445.png)]
进入到线程选项卡,可以看到其已经提示发现死锁,接着点击线程DUMP按钮,即可查看线程堆栈信息。确定死锁位置。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-fwk6ito3-1687955915311)(assets/image-20200724000119109.png)]
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-rxEByNdS-1687955915312)(assets/image-20200724000137854.png)]
2.4.4.4 避免死锁的常见方法
1)避免一个线程同时获取多个锁。
2)避免一个线程在锁内同时占用多个资源,尽量保证每个锁只占用一个资源。
3)尝试使用定时锁,使用lock.tryLock(timeout)来替代使用内部锁机制。
3. 深入volatile关键字
3.1 概述
Java中提供了一种较弱的同步机制,即volatile关键字。可以把它看成是synchronized的轻量级实现,但是其并不能完全替代synchronized,或者说将其当做锁来使用。volatile具有可见性与有序性,但不具有原子性。即通过volatile修饰的共享变量(类的成员变量和静态成员变量)不直接存在于工作线程的副本,而是存在于主内存中。线程每次读取时,都会去主内存中进行读取,从而保证其他线程每次对于该成员变量都能获取最新值。
简单来说,一个共享变量一旦被volatile修饰后,其就具备了两个特性:
- 保证多线程下对该变量的可见性。
- 保证多线程下对该变量的有序性。
3.2 特性详解
3.2.1 不能保证共享变量的原子性
public class AtomicityTest {
public volatile int inc = 0;
public void increment() {
inc++;
}
public static void main(String[] args) throws InterruptedException {
final AtomicityTest test = new AtomicityTest();
for(int i=0;i<10;i++){
new Thread(){
@Override
public void run() {
for(int j=0;j<1000;j++) {
test.increment();
}
};
}.start();
}
//保证前面的线程都执行完
TimeUnit.SECONDS.sleep(3);
System.out.println(test.inc);
}
}
通过上述程序运行结果可以发现,最终结果并非期望的10000,因为volatile并不能保证原子性,因此在多线程下操作时,一个线程可能会读取到另外一个线程并未修改的数据。
3.2.2 能够保证共享变量的可见性
通过volatile修饰的变量,JMM并不会将其放入线程的本地内存,而是放入主内存中。从而该变量对于其他线程都是立即可见的。
3.2.3 能够保证共享变量的有序性
volatile能够禁止指令重排,因此能够在一定程度上保证有序性。当对volatile变量操作时,其前面的操作肯定全部已经执行完毕,其后面的操作肯定还没有执行。
3.3 使用场景
通过前面的讲解可知,volatile不具备原子性,因此其并不能当做锁来使用。一般来说通过volatile修饰的变量都会独立于任何程序,一般volatile会通过与synchronized组合来保证并发时能够正确执行。对其的使用必须同时满足下面两个条件才能保证在并发环境的线程安全:
- 对变量的写操作不依赖于当前值(比如 i++),或者说是单纯的变量赋值(boolean flag = true)
- 该变量没有包含在具有其他变量的不变式中,也就是说,不同的 volatile 变量之间,不能互相依赖。只有在状态真正独立于程序内其他内容时才能使用 volatile。
同时volatile更适用于读多写少的场景。如有N个线程在读值,而只有一个线程在写值,则该值可以通过volatile修饰,即可保证多线程下的可见性,也可以保证变量的原子性。
public class SequenceTest {
static int n =0;
public static void add(){
n++;
}
public static void main(String[] args) {
new Thread(){
@Override
public void run() {
try {
TimeUnit.SECONDS.sleep(3);
for(int i=0;i<200;i++){
add();
}
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}.start();
while (n<100){}
System.out.println("end");
}
}
如果n不加volatile,程序会进入死循环,因为n对于子线程来说是不可见的。当对n添加了volatile后,则程序可以结束。
3.4 扩展
单例模式的DCL为什么要添加volatile
public class Singleton {
private volatile static Singleton instance = null;
public static Singleton getInstance() { //1
if(null == instance) { //2
synchronized (Singleton.class) {//3
if(null == instance) { //4
instance = new Singleton(); //5
}
}
}
return instance;
}
}
在上述代码中对于instance = new Singleton();
在执行时,其内部主要会发生三件事:分配内存、对象初始化、设置instance指向刚分配的地址。因此多线程下在编译时,有可能发生指令重排,假设当线程A在执行第5行代码时,B线程进来执行到第2行代码。假设此时A执行的过程中发生了指令重排序,即先执行了a和c,没有执行b。那么由于A线程执行了c导致instance指向了一段地址,所以B线程判断instance不为null,会直接跳到第6行并返回一个未初始化的对象。