目录

一、预备知识

学习环境准备

进程和线程

进程

线程

两者对比

上下文

并行与并发

同步和异步

二、Java线程入门知识

1.创建线程和运行线程

①继承thread类重写run方法

②实现Runnable接口重写run方法

简化成使用lambda表达式来创建

方法①和方法②进行对比

③FutureTask 配合 Thread

2.线程运行原理

①栈与栈帧

③线程上下文切换

③线程中常见的方法

④start与run方法

⑤sleep和yield

⑥线程优先级

⑦线程中常见方法的应用

sleep的应用

为什么要使用join

join限时同步

interrupt方法详解

⑧两阶段终止模式

⑨线程的状态

五种状态

六种状态

3.本章小结

三、线程安全问题

1.线程安全问题介绍

2.解决方案

①synchronized方案

②原子解决方案

3.变量的线程安全分析(重要)

4.常见的线程安全类

练习题

(后面会讲解monitor,各种锁,以及无锁的CAS,AQS等等)

本篇笔记自己是学习哔哩哔哩上面的黑马的JUC并发编程的个人笔记,记录自己的学习!仅供学习参考;

一、预备知识

预备知识:

  • 接触过Java web开发,jdbc开发,web服务器,分布式框架才会经常遇到这个多线程问题;
  • 最好对函数式编程有一定了解,lambda有一定了解
  • 采用slf4j打印日志
  • 采用lombok简化Java bean的书写

学习环境准备

自己创建一个空的maven项目,然后引入下面的依赖:

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <groupId>org.example</groupId>
    <artifactId>juc_learn</artifactId>
    <version>1.0-SNAPSHOT</version>

    <properties>
        <maven.compiler.source>8</maven.compiler.source>
        <maven.compiler.target>8</maven.compiler.target>
    </properties>

    <dependencies>
        <dependency>
            <groupId>junit</groupId>
            <artifactId>junit</artifactId>
            <version>4.11</version>
            <scope>test</scope>
        </dependency>
        <!-- https://mvnrepository.com/artifact/org.projectlombok/lombok -->
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <version>1.18.22</version>
            <scope>provided</scope>
        </dependency>
        <dependency>
            <groupId>org.slf4j</groupId>
            <artifactId>slf4j-api</artifactId>
            <version>1.7.22</version>
        </dependency>
        <dependency>
            <groupId>ch.qos.logback</groupId>
            <artifactId>logback-classic</artifactId>
            <version>1.2.3</version>
        </dependency>
        <dependency>
            <groupId>org.junit.jupiter</groupId>
            <artifactId>junit-jupiter</artifactId>
            <version>RELEASE</version>
            <scope>compile</scope>
        </dependency>
    </dependencies>

</project>

配置日志文件:logback.log

<?xml version="1.0" encoding="UTF-8"?>
<configuration scan="true">
    <appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
        <encoder>
            <pattern>%date{HH:mm:ss} [%t] %logger - %m%n</pattern>
        </encoder>
    </appender>
    <logger name="c" level="debug" additivity="false">
        <appender-ref ref="STDOUT"/>
    </logger>
    <root level="ERROR">
        <appender-ref ref="STDOUT"/>
    </root>
</configuration>

进程和线程

进程

  • 程序由指令和数据组成,但这些指令要运行,数据要读写,就必须将指令加载至 CPU数据加载至内存。在指令运行过程中还需要用到磁盘、网络等设备。进程就是用来加载指令、管理内存、管理 IO 的。
  • 当一个程序被运行,从磁盘加载这个程序的代码至内存,这时就开启了一个进程。
  • 进程就可以视为程序的一个实例。大部分程序可以同时运行多个实例进程(例如记事本、画图、浏览器 等),也有的程序只能启动一个实例进程(例如网易云音乐、360 安全卫士等)

线程

线程是由进程创建的,是进程的一个实体,是具体干活的人,一个进程可能会有多个线程。线程不独立分配内存,而是共享进程的内存资源,线程可以共享CPU的计算资源;

  • 一个进程之内可以分为一到多个线程。
  • 一个线程就是一个指令流,将指令流中的一条条指令以一定的顺序交给 CPU 执行 。
  • Java 中,线程作为小调度单位,进程作为资源分配的最小单位。 在 windows 中进程是不活动的,只是作 为线程的容器

两者对比

  • 进程基本上相互独立的,而线程存在于进程内,是进程的一个子集
  • 进程拥有共享的资源,如内存空间等,供其内部的线程共享
  • 进程更加强调:内存资源的分配
    线程更加强调:计算资源的分配
  • 进程间通信较为复杂
  • 同一台计算机的进程通信称为 IPC(Inter-process communication)
  • 不同计算机之间的进程通信,需要通过网络,并遵守共同的协议,例如 HTTP
  • 线程通信相对简单,因为它们共享进程内的内存,一个例子是多个线程可以访问同一个共享变量
  • 线程更轻量,线程上下文切换成本一般上要比进程上下文切换低(上下文切换会导致用户态到内核态的转变,是非常消耗cpu资源的)

上下文

从任务管理器中我们可以看到一台电脑上可以运行着非常多的进程,非常非常多的线程,但是我们的逻辑内核个数非常少,这就可以证明对于每一个逻辑内核它在执行的过程中也是按照时间片执行不同的线程的

所以在这里我们就产生了几个问题:

1.我们的进程可以直接创建调度线程吗?QQ运行了一会儿说我累了,不想执行了,微信你来吧,这显然是不合理的;

2.QQ执行了一会儿,不执行了,那等其他线程执行完成之后又轮上了QQ了,QQ还能记得刚才它运行到哪里了吗?

针对第1个问题,任何一个用户的线程是不允许调度其他的线程的,所有线程的调用都由大管家统一调度,这个大管家就是系统内核

第2个问题下一个执行时想要知道上一次的执行结果,就必须在上一次执行之后将运行时的数据进行保存,然后下一次再轮到这个线程的时候再恢复刚刚保存的数据,那么整个过程就出来了. 其中用户线程执行的过程我们称之为【用户态】,内核调度的状态称之为【内核态】,每一个线程运行时产生的数据我们称之为【上下文】,线程的每次切换都需要进行用户态到内和态的来回切换(通过内核调度来进行切换),同时伴随着上下文的切换,是一个比较消耗资源的操作,所以一个计算机当中不是线程越多越好,线程如果太多也是有可能会拖垮整个系统的;

并行与并发

单核cpu下,线程实际还是串行执行的。操作系统中有一个组件叫做任务调度器,将 cpu 的时间片(windows 下时间片最小约为 15 毫秒)分给不同的程序使用,只是由于 cpu 在线程间(时间片很短)的切换非常快,人类感觉是同时运行的 。总结为一句话就是: 微观串行,宏观并行

一般会将这种线程轮流使用 CPU 的做法称为并发, concurrent

CPU

时间片 1

时间片 2

时间片 3

时间片 4

core

线程 1

线程 2

线程 3

线程 4

多核 cpu下,每个 核(core) 都可以调度运行线程,这时候线程可以是并行的。

大概可以这样理解:在同一个时间,做事的人数和事情的数量相对应,一个人干一件事,不需要一个人既做A事情又做B事情;

CPU

时间片 1

时间片 2

时间片 3

时间片 4

core1

线程 1

线程 2

线程 3

线程 4

core2

线程 4

线程 4

线程 2

线程 2

引用 Rob Pike 的一段描述:

并发(concurrent)是同一时间应对(dealing with)多件事情的能力 。

并行(parallel)是同一时间动手做(doing)多件事情的能力。

同步和异步

同步和异步的概念 以调用方的角度讲,如果

  • 需要等待结果返回才能继续运行的话就是同步
  • 不需要等待就是异步

比如:文件流中的read方法就是同步的方法,即当你调用read方法读取文件的时候,虽然IO不占用cpu,但是必须要等到文件读取完成才能继续往下执行指令;

显然这是不太好的,因为在单线程的程序中,这样的话其他的指令就需要一直等待,那么这个时候cpu就是空闲的,怎么能让cpu空闲呢,这多浪费资源呀,而且谁知道你read的文件有多大!cpu的线程调度机制,可以让多个线程轮流来使用cpu,让cpu不再空闲;至于多个线程如何轮流使用有限的CPU资源,这就是操作系统中系统内核干的事情;

案例:比如在项目中,视频文件需要转换格式等操作比较费时,这时开一个新线程处理视频转换,避免阻塞主线程;

结论:

1) 单核 cpu 下,多线程不能实际提高程序运行效率,只是为了能够在不同的任务之间切换,不同线程轮流使用 cpu ,不至于一个线程总占用 cpu,别的线程没法干活 2)多核 cpu 可以并行跑多个线程,但能否提高程序运行效率还是要分情况的

  • 有些任务,经过精心设计,将任务拆分,并行执行,当然可以提高程序的运行效率。但不是所有计算任 务都能拆分(参考后文的【阿姆达尔定律】)
  • 也不是所有任务都需要拆分,任务的目的如果不同,谈拆分和效率没啥意义

3)IO 操作不占用 cpu,只是我们一般拷贝文件使用的是【阻塞 IO】,这时相当于线程虽然不用 cpu,但需要一 直等待 IO 结束,没能充分利用线程。所以才有后面的【非阻塞 IO】和【异步 IO】优化

二、Java线程入门知识

1.创建线程和运行线程

①继承thread类重写run方法

package create_thread;
/**
 * 使用继承thread类创建线程
 */
public class UseThread extends Thread{
    /**
     * 创建线程的时候为什么要重写run方法?
     并不是所有创建线程的方法都要重新run方法,比如实现Callable接口就不需要重新run方法而是重写call方法
     run方法中是用来写 创建的线程中的 业务代码
     */

    @Override
    public void run() {
       System.out.println(2);
    }

    public static void main(String[] args) throws InterruptedException {
        System.out.println(1);
        /**
         * new UseThread().start()表示创建一个新线程并且让新线程启动,也就是说
         * 此时有两条线程了,一个主线程,一个新开辟的线程
         * 因为新开辟线程需要花一点点时间,所以开始的输出是132
         */
        new UseThread().start();  //创建线程实例,启动线程是调用start方法的,然后该线程去执行重写run中的方法体中的代码
        Thread.sleep(10);  //让主线程休眠10毫秒后输出:123;不加这个输出是132
        System.out.println(3);
    }

}

 改造匿名内部类:

package create_thread;
/**
 * 使用匿名内部类创建线程
 */
@Slf4j
public class UseThread2 {
       
    public static void main(String[] args) throws InterruptedException {
        
        Thread t = new Thread(){
            @Override
    		public void run() {
                log.info("running");
      		    System.out.println(2);
    	    }
        };
        //我们可以为使用匿名内部类创建的线程设置线程name
        t.setName("t1");        
        t.start();  
    }
}

②实现Runnable接口重写run方法

/**
 * 使用实现runnable接口重写run方法来创建线程
 * 在使用实现runnable接口的时候发现,这个接口只有一个run方法要实现,并且没有发现如何启动线程,
 * 所以我们要借助thread类来启动创建的线程,通过thread类的构造方法发现我们可以把实现runnable接口的类的实例作为参数传进创建thread的对象的参数中,然后通过这个thread实例来启动创建的新线程
 *
 */
public class UseRunnable implements Runnable{
    @Override
    public void run() {
        System.out.println(2);
    }

    public static void main(String[] args) {
		//这个Runnable里面的run方法代表线程要执行的任务
        new Thread(new UseRunnable()).start();
    }
}

改造成使用匿名内部类的方式:

public class UseRunnable implements Runnable{
    
    public static void main(String[] args) {     
        Runnable r = new Runnable(){
            @Override
    		public void run() {
        	System.out.println(2);
    		}	
        };
        
        System.out.println(1);
		//这个Runnable里面的run方法代表线程要执行的任务
        //new Thread(r,"ThreadName").start();
        Thread t = new Thread(r,"ThreadName");
        t.start();
    }
}

简化成使用lambda表达式来创建

使用匿名:

public class UseRunnable implements Runnable{   
    public static void main(String[] args) {     
        Runnable r = new Runnable(){
            @Override
    		public void run() {
        	System.out.println(2);
    		}	
        };      
        Thread t = new Thread(r,"ThreadName");
        t.start();
    }
}

简化后:

public class UseRunnable implements Runnable{    
    public static void main(String[] args) {     
        Runnable r = () -> {
        	System.out.println(2);
        };
        Thread t = new Thread(r,"ThreadName");
        t.start();
    }
}

再次简化:
public class UseRunnable implements Runnable{    
    public static void main(String[] args) {     
        Thread t = new Thread(() -> {
        	System.out.println(2);};
        	,"ThreadName");
        	t.start();
    }
}

方法①和方法②进行对比

方法 1 是把线程和任务合并在了一起;

方法 2 是把线程和任务分开了,用 Runnable 更容易与线程池等高级 API 配合,用 Runnable 让任务类脱离了 Thread 继承体系,更灵活。(推荐使用Runnable) 通过查看源码可以发现,方法一和方法二都是通过调用 Thread 类中的 run 方法来执行线程的;

③FutureTask 配合 Thread

就是实现Callable来创建线程,这种方式是可以拿到线程中的返回值;但是实现Runnable接口就不能拿到线程的返回值;

package create_thread;

import java.util.concurrent.Callable;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.FutureTask;

/**
 *实现Callable来创建线程,并且可以拿到线程中的返回值
 */
public class UseCallable implements Callable<Integer> {

    public static void main(String[] args) throws ExecutionException, InterruptedException {
        System.out.println(2);
       //保存一个将来的返回值
        FutureTask futureTask = new FutureTask(new UseCallable(),{
            @Override
    		public Integer call() throws Exception {
        		Thread.sleep(5000);
        		return 1;
  			}
        });
        System.out.println(3);
        new Thread(futureTask,"t1").start();
        System.out.println(4);
        //这个FutureTask中的get方法是阻塞主线程的,等待线程的返回值  此时主线程卡在这里不动,等返回值
       Integer result = (Integer) futureTask.get();//可以用来控制程序执行的顺序
       System.out.println(result);
       System.out.println(5);
    }
}
// future接口的相关方法
public interface Future<V> {
	// 取消任务
	boolean cancel(boolean mayInterruptIfRunning);
	// 获取任务执行结果
	V get() throws InterruptedException, ExecutionException;
	// 获取任务执行结果,带有超时时间限制
	V get(long timeout, TimeUnit unit) throws InterruptedException,                             ExecutionException,  TimeoutException;
	// 判断任务是否已经取消
	boolean isCancelled();
	// 判断任务是否已经结束
	boolean isDone();
}

2.线程运行原理

①栈与栈帧

拟机栈描述的是Java方法执行的内存模型: 每个方法被执行的时候都会同时创建一个栈帧(stack frame)用于存储局部变量表、操作数栈、动态链接、方法出口等信息,【是属于线程的私有的】。(我们每次启用debug的方式运行程序看见的那些变量的值就是栈帧(frames)里面存储的数据)

每个栈由多个栈帧(Frame)组成,对应着每次方法调用时所占用的内存 ; 当java中使用多线程时,每个线程都会维护它自己的栈帧!每个线程只能有一个活动栈帧,对应着当前正在执行的那个方法当方法执行完会来到栈帧中的方法出口地址位置,然后从栈(先进后出)中 pop 出栈帧。也就是说方法执行完毕就会在内存中被释放,但是方法执行完后的返回值的地址是会被记录的;当main方法中的所有代码被执行完,程序也就运行结束了;

java 并发编程 框架 java并发编程从入门到精通_java 并发编程 框架

java 并发编程 框架 java并发编程从入门到精通_intellij idea_02

单线程调用发方法的栈帧图的详解:

java 并发编程 框架 java并发编程从入门到精通_时间片_03

 

java 并发编程 框架 java并发编程从入门到精通_java_04

 程序计数器执行的是字节码文件,这里我们使用了Java代码进行了简化;大概可以这么理解一下:程序计数器是负责记录要执行的代码行(字节码指令),然后把将要执行的字节码指令交给cpu执行;

多线程的栈帧运行:线程的栈内存是相互独立的,每个线程拥有自己独立的栈内存,栈内存里面有多个栈帧,它们互不干扰;

③线程上下文切换

因为以下一些原因导致 cpu 不再执行当前的线程,转而执行另一个线程的代码被动原因:

  • 线程的 cpu 时间片用完(每个线程轮流执行),
  • 垃圾回收,
  • 有更高优先级的线程需要运行;

主动原因:线程自己调用了 sleep、yield、wait、join、park、synchronized、lock 等方法;

当 上下文切换发生时,需要由操作系统保存当前线程的状态,并恢复另一个线程的状态,Java 中对应的概念就是程序计数器(Program Counter Register),它的作用是记住下一条 jvm 指令的执行地址,是线程私有的。

  • 状态包括程序计数器、虚拟机栈中每个栈帧的信息,如局部变量、操作数栈、返回地址等
  • Context Switch 频繁发生会影响性能

③线程中常见的方法

下面的表格用来当做查询使用就行;

方法

功能

说明

public void start()

启动一个新线程;Java虚拟机调用此线程的run方法

start 方法只是让线程进入就绪,里面代码不一定立刻 运行(CPU 的时间片还没分给它)。每个线程对象的 start方法只能调用一次,如果调用了多次会出现 IllegalThreadStateException

public void run()

线程启动后调用该方法

如果在构造 Thread 对象时传递了 Runnable 参数,则 线程启动后会调用 Runnable 中的 run 方法,否则默 认不执行任何操作。但可以创建 Thread 的子类对象, 来覆盖默认行为

public void setName(String name)

给当前线程取名字

public void getName()

获取当前线程的名字。线程存在默认名称:子线程是Thread-索引,主线程是main

public static Thread currentThread()

获取当前线程对象,代码在哪个线程中执行

public static void sleep(long time)

让当前线程休眠多少毫秒再继续执行。Thread.sleep(0) : 让操作系统立刻重新进行一次cpu竞争

public static native void yield()

提示线程调度器让出当前线程对CPU的使用

主要是为了测试和调试

public final int getPriority()

返回此线程的优先级

public final void setPriority(int priority)

更改此线程的优先级,常用1 5 10

java中规定线程优先级是1~10 的整数,较大的优先级 能提高该线程被 CPU 调度的机率

public void interrupt()

中断这个线程,异常处理机制

public static boolean interrupted()

判断当前线程是否被打断,清除打断标记

public boolean isInterrupted()

判断当前线程是否被打断,不清除打断标记

public final void join()

等待这个线程结束

public final void join(long millis)

等待这个线程死亡millis毫秒,0意味着永远等待

public final native boolean isAlive()

线程是否存活(还没有运行完毕)

public final void setDaemon(boolean on)

将此线程标记为守护线程或用户线程

public long getId()

获取线程长整型 的 id

id 唯一

public state getState()

获取线程状态

Java 中线程状态是用 6 个 enum 表示,分别为: NEW, RUNNABLE, BLOCKED, WAITING, TIMED_WAITING, TERMINATED

public boolean isInterrupted()

判断是否被打 断

不会清除 打断标记

④start与run方法

package thread;
import lombok.extern.slf4j.Slf4j;
@Slf4j
public class Test1 {
    public static void main(String[] args) {
        Thread t1 = new Thread("t1") {
            @Override
            public void run() {
                log.info("running...");
            }
        };
        //t1是线程对象,这个对象里面是有run方法的,那么直接使用对象点run方法与对象点start方法有什么区别?        
        t1.start();    //同一个线程是不能多次start的,否则会报错    
        //t1.run();   这样调用的话,run方法是主线程来执行的,没有启动新的线程 ,这样的话并不能起到一个异步的效果    
    }
}

⑤sleep和yield

sleep:

  1. 调用 sleep 会让当前线程(在哪个线程中调用sleep方法就是那个线程进入睡眠等待)从 Running 进入 Timed Waiting 状态(阻塞) ,sleep()是线程线程类(Thread)的方法,调用会暂停此线程指定的时间,但监控依然保持,不会释放对象锁,到时间自动恢复,但是期间是会释放cpu的时间片的使用,休眠时间到期后是直接获取到cpu的时间片还是需要去和其他线程竞争cpu的时间片?答案是并不能保证该线程马上拿到CPU的使用权,需要等待CPU分配时间片来执行
  2. 其它线程可以使用 interrupt 方法打断正在睡眠的线程,这时 sleep 方法会抛出 InterruptedException
  3. 睡眠结束后的线程未必会立刻得到执行,因为并不能保证该线程马上拿到CPU的使用权。
  4. 建议用 TimeUnit (jdk1.5以后提供的)的 sleep 代替 Thread 的 sleep 来获得更好的可读性 。其底层还是sleep方法。

yiled: 会让出cpu的使用权。如果没有其他的线程使用cpu,最后任务调度器还是会把cpu的使用权给yiled的线程;

  1. 调用 yield 会让当前线程从 Running 进入 Runnable 就绪状态,然后调度执行其它线程
  2. 具体的实现依赖于操作系统的任务调度器

区别:阻塞状态与就绪状态

就绪状态还是有可能获取CPU的使用权的,但是任务调度器是不会把cpu的使用权交个阻塞状态的线程的;

sleep可以指定等待的时间,而yield只是马上把CPU的使用权让出去,但是也可能又拿到这个CPU的使用权的(就是没有线程需要使用cpu的时候);

⑥线程优先级

这个设置线程的优先级并不一定靠谱,靠谱的还是这个任务调度器;

  • 线程优先级会提示(hint)调度器优先调度该线程,但它仅仅是一个提示,调度器可以忽略它
  • 如果 cpu 比较忙,那么优先级高的线程会获得更多的时间片,但 cpu 闲时,优先级几乎没作用

⑦线程中常见方法的应用

sleep的应用

在没有利用 cpu 来计算时,不要让 while(true) 空转浪费 cpu,这时可以使用 yield 或 sleep 来让出 cpu 的使用权 给其他程序;一般来说只要使用了while的死循环,那就尽量在循环中使用一个sleep方法,这样可以避免cpu一直在空转,如果是单线程的话那么就可能导致其他线程没有机会使用到cpu,但是在循环中写sleep方法,这可以让死循环的线程让出cpu给其他线程使用

while(true) {
    try {
        Thread.sleep(50);
    } catch (InterruptedException e) {
        e.printStackTrace();
    }
}
  • 可以用 wait 或 条件变量达到类似的效果
  • 不同的是,后两种都需要加锁,并且需要相应的唤醒操作,一般适用于要进行同步的场景
  • sleep 适用于无需锁同步的场景

为什么要使用join

先来看看下面的代码,你觉得会r会打印什么数字来?

static int r = 0;
public static void main(String[] args) throws InterruptedException {
    test1();
}
private static void test1() throws InterruptedException {
    log.debug("开始");
    Thread t1 = new Thread(() -> {
        log.debug("开始");
        sleep(1000);
        log.debug("结束");
        r = 10;
    });
    t1.start();
    log.debug("结果为:{}", r);
    log.debug("结束");
}

分析

  • 因为主线程和线程 t1 是并行执行的,t1 线程需要 1 秒之后才能把10赋值给r,但是这个时候主线程早就运行完了
  • 而主线程一开始就要打印 r 的结果,所以只能打印出 r=0

解决方法

  • 用 sleep 行不行?为什么?

不行,因为sleep只是让调用它的线程进行睡眠,没有调用它的线程还是会继续执行;

  • 用 join,把t1.join()加在 t1.start() 之后即可(这个t1.join()表示其他线程要等待t1运行完之后,其他线程才能继续运行t1.join()这行代码后面的代码)

以调用方角度来讲,如果

  • 需要等待结果返回,才能继续运行就是同步
  • 不需要等待结果返回,就能继续运行就是异步

等待多个线程的结果:

static int r1 = 0;
static int r2 = 0;
public static void main(String[] args) throws InterruptedException {
    test2();
}
private static void test2() throws InterruptedException {
    Thread t1 = new Thread(() -> {
        sleep(1000); //毫秒
        r1 = 10;
    });
    Thread t2 = new Thread(() -> {
        sleep(2000);
        r2 = 20;
    });
    long start = System.currentTimeMillis();
    t1.start();
    t2.start();
    t1.join();
    t2.join();
    long end = System.currentTimeMillis();
    log.debug("r1: {} r2: {} cost: {}", r1, r2, end - start);
}

分析如下

  • 第一个 join:等待 t1 时, t2 并没有停止, 而在运行
  • 第二个 join:1s 后, 执行到此, t2 也运行了 1s, 因此也只需再等待 1s

最后等待的时间是接近2秒;

join限时同步

这个join方法里面还可以传一个时间,表示其他线程要等待的时间;

当线程执行时间没有超过join设定时间, r的值和没设置时间的结果一致;

static int r = 0;
public static void main(String[] args) throws InterruptedException {
    test3();
}
public static void test3() throws InterruptedException {
    Thread t = new Thread(() -> {
        sleep(1000);
        r = 10;
    });
    long start = System.currentTimeMillis();
    t1.start();
    // 主线程会在这里等待t1线程执行完成后才会继续往后执行
    t1.join(1500);
    long end = System.currentTimeMillis();
    log.debug("r: {} cost: {}", r, end - start);
}

输出结果:

[main] c.TestJoin - r: 10  cost: 1010

当线程执行时间超时join等待时间:

static int r = 0;
public static void main(String[] args) throws InterruptedException {
    test3();
}
public static void test3() throws InterruptedException {
    Thread t = new Thread(() -> {
        sleep(2000);
        r = 10;
    });
    long start = System.currentTimeMillis();
    t.start();
    // join的时间小于线程执行的时间,相当于这个join没有怎么生效,因为这个时候主线程还是会在t1线程之前执行下面的代码
    t.join(1500);
    long end = System.currentTimeMillis();
    log.debug("1: {} cost: {}", r, end - start);
}

输出:

[main] c.TestJoin - r: 0 cost: 1502

interrupt方法详解

interrupt可以打断阻塞的线程或者是正在运行的线程;

被打断的线程会有两种状态,要么是继续运行要么就是停止运行,而且这个是被打断线程自己来选择自己被打断后状态;而且打断后会帮被打断的线程做一个标记,这个标记是一个布尔值;

interrupt的本质是将线程的打断标记设为true(未打断之前标记是false),并调用线程的三个parker对象(C++实现级别)unpark该线程。

基于以上本质,有如下说明:

  • 打断线程不等于中断线程,有以下两种情况:
  • 打断正在运行中的线程并不会影响线程的运行,会把标记值变成ture,可以自行决定后续处理,通过改变这个打断标记来决定是否要继续运行,是写这个线程的人来决定的
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.Test;

@Slf4j(topic = "c.Test1")
public class Test1 {
    public static void main(String[] args) {
        try {
            test2();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }


    private static void test2() throws InterruptedException {
        Thread t2 = new Thread(()->{
            while(true) {
                Thread current = Thread.currentThread();
                boolean interrupted = current.isInterrupted();
                //自己来决定是否停止现场的运行
                if(interrupted) {
                    log.debug(" 打断状态: {}", interrupted);
                    break;
                }
            }
        }, "t2");
        t2.start();
        //让主线程睡眠一下
        Thread.sleep(500);
        t2.interrupt();
    }
}

运行结果: [t2] c.TestInterrupt - 打断状态: true

  • 打断阻塞中(sleep,wait,join)的线程会让此线程产生一个InterruptedException异常,结束线程的运行。但如果该异常被线程捕获住,该线程依然可以自行决定后续处理(终止运行,继续运行,做一些善后工作等等),而且这个打断阻塞中的线程是会重置标记值为false;

interrupt 打断线程有两种情况总结,如下:

  • 如果一个线程在在运行中被打断,打断标记会被置为 true 。
  • 如果是打断因sleep wait join 方法而被阻塞的线程,会将打断标记置为 false 。

⑧两阶段终止模式

在一个线程 T1 中如何“优雅”终止线程 T2?这里的【优雅】指的是给 T2 一个料理后事的机会。

错误思路:

  • 使用线程对象的 stop() 方法停止线程
  • stop 方法会真正杀死线程,如果这时线程锁住了共享资源,那么当它被杀死后就再也没有机会释放锁, 其它线程将永远无法获取锁
  • 使用 System.exit(int) 方法停止线程
  • 目的仅是停止一个线程,但这种做法会让整个程序都停止

两阶段终止模式:

java 并发编程 框架 java并发编程从入门到精通_java_05

代码实现:主要是使用了interrupt这个方法:

/**
 * 使用 interrupt 进行两阶段终止模式
 */
@Slf4j
public class Test {

    public static void main(String[] args) throws InterruptedException {
        TwoParseTermination twoParseTermination = new TwoParseTermination();
        twoParseTermination.start();
        Thread.sleep(3500);
        twoParseTermination.stop();
    }

}

@Slf4j
class TwoParseTermination {

    private Thread monitor;

    // 启动线程
    public void start() {
        monitor = new Thread(() -> {
            while (true) {
                //拿到当前线程
                Thread thread = Thread.currentThread();
                if(thread.isInterrupted()) { // 调用 isInterrupted拿到标记值 这个不会清除标记
                    log.info("料理后事 ...");
                    break;  //终止线程
                } else {
                    try {
                        Thread.sleep(1000);
                        log.info("执行监控的功能 ...");
                    } catch (InterruptedException e) {
                        log.info("设置打断标记 ...");
                        //因为捕获异常后标记值变成了false,这里再打断一下,就相当于把这个正在运行的线程给打断了,所以这里的标记值又变成了ture,如果这里不重置这个标记值那么这个线程是会一直运行的
                        thread.interrupt();
                        e.printStackTrace();
                    }
                }
            }
        }, "monitor");
        monitor.start();
    }

    // 终止线程
    public void stop() {
        monitor.interrupt();
    }
}

 isInterrupted() 与 interrupted() 比较,如下: 首先,isInterrupted 是实例方法,interrupted 是静态方法,它们的用处都是查看当前打断的状态,但是 isInterrupted 方法查看线程的时候,不会将打断标记清空,也就是置为 false,interrupted 查看线程打断状态后,会将打断标志置为 false,也就是清空打断标记,简单来说,interrupt() 方法类似于 setter 设置中断值,isInterrupted() 类似于 getter 获取中断值,interrupted() 类似于 getter + setter 先获取中断值,然后清除标志。

主要是区分会不会重置线程的标记值;

⑨线程的状态

五种状态

java 并发编程 框架 java并发编程从入门到精通_java_06

操作系统 层面来描述:

  • 【初始状态】仅是在语言层面创建了线程对象,还未与操作系统线程关联
  • 【可运行状态】(就绪状态)指该线程已经被创建(与操作系统线程关联),可以由 CPU 调度执行
  • 【运行状态】指获取了 CPU 时间片运行中的状态
  • 当 CPU 时间片用完,会从【运行状态】转换至【可运行状态】,会导致线程的上下文切换(cpu的使用时间片只会分给可运行状态的线程)
  • 【阻塞状态】
  • 如果调用了阻塞 API,如 BIO 读写文件,这时该线程实际不会用到 CPU,会导致线程上下文切换,进入 【阻塞状态】
  • 等 BIO 操作完毕,会由操作系统唤醒阻塞的线程,转换至【可运行状态】
  • 与【可运行状态】的区别是,对【阻塞状态】的线程来说只要它们一直不唤醒,调度器就一直不会考虑 调度它们
  • 【终止状态】表示线程已经执行完毕,生命周期已经结束,不会再转换为其它状态;

六种状态

Java API 层面来描述的:根据 Thread.State 枚举,分为六种状态

java 并发编程 框架 java并发编程从入门到精通_java 并发编程 框架_07

  • NEW 线程刚被创建,但是还没有调用 start() 方法
  • RUNNABLE 当调用了 start() 方法之后,注意,Java API 层面的 RUNNABLE 状态涵盖了 操作系统 层面的 【可运行状态】、【运行状态】和【阻塞状态】(由于 BIO 导致的线程阻塞,在 Java 里无法区分,仍然认为 是可运行)
  • BLOCKED , WAITING , TIMED_WAITING 都是 Java API 层面对【阻塞状态】的细分,后面会在状态转换一节 详述
  • TERMINATED 当线程代码运行结束

3.本章小结

本章的重点在于掌握

  • 线程创建的方式
  • 线程重要 api,如 start,run,sleep,join,interrupt 等
  • 线程状态
  • 应用方面
  • 异步调用:主线程执行期间,其它线程异步执行耗时操作
  • 提高效率:并行计算,缩短运算时间
  • 同步等待:join
  • 统筹规划:合理使用线程,得到最优效果
  • 原理方面
  • 线程运行流程:栈、栈帧、上下文切换、程序计数器
  • Thread 两种创建方式 的源码
  • 模式方面
  • 终止模式之两阶段终止

三、线程安全问题

1.线程安全问题介绍

        共享数据带来的问题:数据容易不同步,一个线程修改了共享的数据,但是其他线程可能无法感知到最新的数据;并发执行的时候每个线程从内存中拿到的数据并不都是一样的,因为线程在执行修改数据操作的时候是必须先从内存中获取到数据,然后修改完成后再写回内存,这两个阶段就非常容易出现数据不同步的问题,比如A修改了数据,然后准备写回内存的时候,此时B线程在A写回内存之前获取到了内存的数据,那么这个时候B线程获取到的数据就不是最新的数据;那么在更大的并发的时候这个问题就会更加明显!!!

 代码:

public static int count = 0;
    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(() -> {
            for (int i = 1;i < 5000; i++){
                count++;
            }
        });
        Thread t2 = new Thread(() -> {
            for (int i = 1;i < 5000; i++){
                count--;
            }
        });
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        log.debug("count的值是{}",count);
    }

如上代码,当执行 count++ 或者 count-- 操作的时候,从字节码分析,实际上是 4 步操作。

count++; // 操作字节码如下:
getstatic i // 获取静态变量i的值
iconst_1 // 准备常量1
iadd // 自增
putstatic i // 将修改后的值存入静态变量i

count--; // 操作字节码如下:
getstatic i // 获取静态变量i的值
iconst_1 // 准备常量1
isub // 自减
putstatic i // 将修改后的值存入静态变量i

当 CPU 时间片分给 t1 线程时,t1 线程去读取变量值为 0 并且执行 ++ 的操作,如上在字节码自增操作中,当 t1 执行完自增,还没来得急将修改后的值存入静态变量时,假如线程的时间片用完了,并且 CPU 将时间片分配给 t2 线程,t2 线程拿到时间片执行自减操作,并且将修改后的值存入静态变量,此时 count 的值为 -1,但是当 CPU 将时间片分给经历了上下文切换的 t1 线程时,t1 将修改后的值存入静态变量,此时 counter 的值为 1,覆盖了 t2 线程执行的结果,出现了丢失更新,这就是多线对共享资源读取的问题。

主要原因是:指令交错和线程上下文切换

  • 一个程序运行多个线程本身是没有问题的
  • 问题出在多个线程访问共享资源
  • 多个线程读共享资源其实也没有问题
  • 在多个线程对共享资源读写操作时发生指令交错,就会出现问题 !
  • 一段代码块内如果存在对共享资源的多线程读写操作,称这段代码块为临界区

多个线程在临界区内执行,由于代码的执行序列不同而导致结果无法预测,称之为发生了竞态条件

2.解决方案

为了避免临界区的竞态条件发生,有多种手段可以达到目的。

  • 阻塞式的解决方案:synchronized,Lock
  • 非阻塞式的解决方案:原子变量

注意:

虽然 java 中互斥和同步都可以采用 synchronized 关键字来完成,但它们还是有区别的:

  • 互斥是保证临界区的竞态条件发生,同一时刻只能有一个线程执行临界区代码 ;
  • 同步是由于线程执行的先后、顺序不同、需要一个线程等待其它线程运行到某个点;

①synchronized方案

使用synchronized锁对象:

synchronized(对象) // 线程1持有锁, 线程2阻塞等待线程一释放锁(blocked)  同一个时间段只能有一个线程持有对象锁,其他线程只能等待锁的释放(被阻塞)
{
    临界区
}

解决上面代码的并发问题:

class Room {
    int value = 0;
    public void increment() {
        synchronized (this) {
            value++;
        }
    }
    public void decrement() {
        synchronized (this) {
            value--;
        }
    }
    public int get() {
        synchronized (this) {
            return value;
        }
    }
}

@Slf4j
public class Test1 {

    public static void main(String[] args) throws InterruptedException {
        Room room = new Room();
        Thread t1 = new Thread(() -> {
            for (int j = 0; j < 5000; j++) {
                room.increment();
            }
        }, "t1");
        Thread t2 = new Thread(() -> {
            for (int j = 0; j < 5000; j++) {
                room.decrement();
            }
        }, "t2");
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        log.debug("count: {}" , room.get());
    }
}

思考

synchronized 实际是用对象锁保证了临界区内代码的原子性临界区内的代码对外是不可分割的,不会被线程切 换所打断。 要保护共享资源必须要多个线程锁住的是同一个对象,这样才能避免并发修改时共享数据的有效性;

就是哪个对象的数据被多个线程共享,那么这个对象的数据在多线程的程序中就可能会出现安全问题,那么为了避免多个线程同时对数据进行读或者写操作,那么就得让使用这个共享数据的所有线程加上同一把锁,记住是加同一把锁!!!这样才能保证同一时间只有一个线程在使用共享的数据;

synchronized加在方法上:

注意:synchronized加在方法上并不是代表就是锁方法,synchronized只能锁对象!只不过synchronized加在方法上是表示锁this对象!synchronized加载静态方法上表示的是锁类对象(class类的对象),这样就会导致加锁的的范围太大了一般不建议这样做,因为synchronized的性能是又加锁的代码块的大小来决定的;

不加 synchronzied 的方法就好比不遵守规则的人,不去老实排队(好比翻窗户进去的)

class Test{
    public synchronized void test() {

    }
}
//等价于
class Test{
    public void test() {
        //实例方法锁的是this实例对象,就是哪个实例对象调用这个方法,那么就是锁主调用这个方法的实例对象
        synchronized(this) {

        }
    }
}

----------------------------------------------------------------------------------
class Test{
    public synchronized static void test() {
    }
}
等价于
class Test{
    public static void test() {
    	//静态方法锁的是类对象
        synchronized(Test.class) {

        }
    }
}

一定要学会判断锁住的是哪一个对象!当锁住的是实例对象的时候,如果new了两个这样的实例对象,那么当这两个实例对象分别在两个线程中调用加了锁的方法的时候,其实这个时候这两个线程的执行顺序不是互斥的,因为它们持有的不是同一把锁

@Slf4j(topic = "c.Number")
class Number{
    public synchronized void a() {
        sleep(1);
        log.debug("1");
    }
    public synchronized void b() {
        log.debug("2");
    }
}
public static void main(String[] args) {
    Number n1 = new Number();
    Number n2 = new Number();
    new Thread(()->{ n1.a(); }).start();
    new Thread(()->{ n2.b(); }).start();   //这两个线程持有的并不是同一把锁
}

运行结果:先输出2 1s 后输出 1

②原子解决方案

后面会在CAS讲原子解决方案;

3.变量的线程安全分析(重要)

成员变量和静态变量是否线程安全?

  • 如果它们没有共享,则线程安全
  • 如果它们被共享了,根据它们的状态是否能够改变,又分两种情况
  • 如果只有读操作,则线程安全
  • 如果有读写操作,则这段代码是临界区,需要考虑线程安全

局部变量是否线程安全? (方法里面或者是循环里面或者是if里面的变量等。。。)

  • 局部变量是线程安全的
  • 但局部变量引用的对象则未必
  • 如果该对象没有逃离方法的作用访问,它是线程安全的
  • 如果该对象逃离方法的作用范围,需要考虑线程安全;

局部变量线程安全分析:

public static void test1() {
    int i = 10;
    i++;
}

这个方法里面进行了i++操作,而且还不是原子性的操作,那么这个数据i是安全的吗?

每个线程调用 test1() 方法时局部变量 i,局部变量会在每个线程的栈帧内存中被创建多份,因此不存在共享;

java 并发编程 框架 java并发编程从入门到精通_java_08

 局部变量的引用稍有不同 :先看一个成员变量的例子

class ThreadUnsafe {
    //共享变量在方法的作用范围外,是可能会导致现场不安全的,因为这个变量可以被多个线程同时修改
    ArrayList<String> list = new ArrayList<>();
    public void method1(int loopNumber) {
        for (int i = 0; i < loopNumber; i++) {
            // { 临界区, 会产生竞态条件
            method2();
            method3();
            // } 临界区
        }
    }
    private void method2() {
        list.add("1");
    }
    private void method3() {
        list.remove(0);
    }
}

进行测试:

static final int THREAD_NUMBER = 2;
static final int LOOP_NUMBER = 200;
public static void main(String[] args) {
    ThreadUnsafe test = new ThreadUnsafe();
    for (int i = 0; i < THREAD_NUMBER; i++) {
        new Thread(() -> {
            test.method1(LOOP_NUMBER);
        }, "Thread" + i).start();
    }
}

可能出现的一种bug : 如果线程2 还未 add,线程1 remove 就会报空指针异常;

java 并发编程 框架 java并发编程从入门到精通_java 并发编程 框架_09

分析:

  • 无论哪个线程中的 method2 引用的都是同一个对象中的 list 成员变量
  • method3 与 method2 分析相同

将 list 修改为局部变量:就可以实现线程安全;

class ThreadSafe {
    public final void method1(int loopNumber) {
        ArrayList<String> list = new ArrayList<>();
        for (int i = 0; i < loopNumber; i++) {
            method2(list);
            method3(list);
        }
    }
    private void method2(ArrayList<String> list) {
        list.add("1");
    }
    private void method3(ArrayList<String> list) {
        list.remove(0);
    }
}

那么就不会有上述线程安全问题了;

java 并发编程 框架 java并发编程从入门到精通_System_10

分析:

  • list 是局部变量,每个线程调用时会创建其不同实例,没有共享
  • 而 method2 的参数是从 method1 中传递过来的,与 method1 中引用同一个对象
  • method3 的参数分析与 method2 相同;

但是当我们把这个局部变量暴露在方法的外面,那么还是线程安全的吗?

方法访问修饰符带来的思考,如果把 method2 和 method3 的方法修改为 public 会不会代理线程安全问题?

  • 情况1:有其它线程调用 method2 和 method3 (这种情况仍然是线程安全的,因为即便其他线程调用了这个 method2 和 method3方法,那么它们传进去的参数肯定是和method1中的不一样)
  • 情况2:在 情况1 的基础上,为 ThreadSafe 类添加子类,子类覆盖 method2 或 method3 方法;(这个可能会引起线程安全问题,因为你也不知道人家重写你的方法有没有把方法中的引用暴露给其他线程,如果暴露了那么就会导致其他线程通过这个子类重写的方法来访问和改变你方法的局部变量)
    比如:
class ThreadSafe {
    public final void method1(int loopNumber) {
        ArrayList<String> list = new ArrayList<>();
        for (int i = 0; i < loopNumber; i++) {
            method2(list);
            method3(list);
        }
    }
    private void method2(ArrayList<String> list) {
        list.add("1");
    }
    private void method3(ArrayList<String> list) {
        list.remove(0);
    }
}
class ThreadSafeSubClass extends ThreadSafe{
    @Override
    public void method3(ArrayList<String> list) {
        new Thread(() -> {
            //这里把方法的引用变量暴露给其他线程了
            list.remove(0);
        }).start();
    }
}
  • 这里告诉我们方法的访问控制符是可以来保护我们的程序的,私有的方法限制了子类不能覆盖它,这样就可以避免方法中的局部引用变量暴露出来被其他线程修改;

从这个例子可以看出 private 或 final 提供【安全】的意义所在,请体会开闭原则中的【闭】;

4.常见的线程安全类

  • String
  • Integer
  • StringBuffer
  • Random
  • Vector
  • Hashtable
  • java.util.concurrent 包下的类

这里说它们是线程安全的是指,多个线程调用它们同一个实例的某个方法时,是线程安全的

  • 它们的每个方法是原子的
  • 但注意它们多个方法的组合不是原子的

不可变类线程安全性

String、Integer 等都是不可变类,因为其内部的状态不可以改变,因此它们的方法都是线程安全的 有同学或许有疑问,String 有 replace,substring 等方法【可以】改变值啊,那么这些方法又是如何保证线程安全的呢?

因为调用这些方法返回的已经是一个新创建的对象了。 比如string的源码

public String substring(int beginIndex, int endIndex) {
        if (beginIndex < 0) {
            throw new StringIndexOutOfBoundsException(beginIndex);
        }
        if (endIndex > value.length) {
            throw new StringIndexOutOfBoundsException(endIndex);
        }
        int subLen = endIndex - beginIndex;
        if (subLen < 0) {
            throw new StringIndexOutOfBoundsException(subLen);
        }
        return ((beginIndex == 0) && (endIndex == value.length)) ? this
                : new String(value, beginIndex, subLen); // 新建一个对象,然后返回,没有修改等操作,是线程安全的。
    }

代码分析:

public class MyServlet extends HttpServlet {
    // 是否安全   --->线程安全的,因为 每当一个新的线程来访问就会new一个新的UserServiceImpl对象
    private UserService userService = new UserServiceImpl();

    public void doGet(HttpServletRequest request, HttpServletResponse response) {
        userService.update(...);
    }
}
public class UserServiceImpl implements UserService {
    // 是否安全    --->线程安全的,因为 每当一个新的线程来访问就会new一个新的UserDaoImpl对象
    private UserDao userDao = new UserDaoImpl();
    public void update() {
        userDao.update();
    }
}
public class UserDaoImpl implements UserDao {
    // 是否安全  --->线程不安全,因为这个Connection对象是共享对象,在并发中容易出现问题
    private Connection conn = null;
    public void update() throws SQLException {
        String sql = "update user set password = ? where username = ?";
        conn = DriverManager.getConnection("","","");
        // ...
        conn.close();
    }
}

//创建Connection连接的正确方式:把Connection对象变成方法的局部变量
try (Connection conn = DriverManager.getConnection("","","")){
 // ...
 } catch (Exception e) {
 // ...
 }

练习题

测试下面代码是否存在线程安全问题,并尝试改正

@Slf4j(topic = "c.ExerciseTransfer")
public class ExerciseTransfer {
    public static void main(String[] args) throws InterruptedException {
        Account a = new Account(1000);
        Account b = new Account(1000);
        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 1000; i++) {
                a.transfer(b, randomAmount());
            }
        }, "t1");
        Thread t2 = new Thread(() -> {
            for (int i = 0; i < 1000; i++) {
                b.transfer(a, randomAmount());
            }
        }, "t2");
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        // 查看转账2000次后的总金额
        log.debug("total:{}",(a.getMoney() + b.getMoney()));
    }
    // Random 为线程安全
    static Random random = new Random();
    // 随机 1~100
    public static int randomAmount() {
        return random.nextInt(100) +1;
    }
}
class Account {
    private int money;
    public Account(int money) {
        this.money = money;
    }
    public int getMoney() {
        return money;
    }
    public void setMoney(int money) {
        this.money = money;
    }
    public void transfer(Account target, int amount) {
        if (this.money > amount) {
            this.setMoney(this.getMoney() - amount);
            target.setMoney(target.getMoney() + amount);
        }
    }
}

这样改正行不行,为什么?

  • 不行,因为不同线程调用此方法,将会锁住不同的实例对象;也就是说不同的线程来访问这个transfer方法的时候,它们带的都不是同一把锁,那么你加的锁当然是没有达到相应的效果;
public synchronized void transfer(Account target, int amount) {
    if (this.money > amount) {
        this.setMoney(this.getMoney() - amount);  
        target.setMoney(target.getMoney() + amount);
    }
}

上面的代码就相当于:
public  void transfer(Account target, int amount) {
	synchronized(this){ 
    if (this.money > amount) {
        this.setMoney(this.getMoney() - amount);  
        target.setMoney(target.getMoney() + amount); 
    }
    
    }
}

解决方法:我们发现里面的操作都会针对Account类中的money属性,即便是不同的现场进来最后通过get,set方法访问的还是Account中的money属性,所以我们就可以把Account类的类对象给锁住就行;

public  void transfer(Account target, int amount) {
	synchronized(Account.class){ 
    if (this.money > amount) {
        this.setMoney(this.getMoney() - amount); 
        target.setMoney(target.getMoney() + amount); 
    }
    
    }
}