多线程学习笔记

多线程与并发概念

1.1 并发与多线程
进程:
大部分操作系统都是多任务操作系统。什么是多任务?指的是每个系统在同时能够运行多少个进程。如:在window中,开着一个腾讯视频,开着一个IDEA,开着一个QQ谈情说爱………。系统中同时有多个程序在内存中运行着,每一个运行的程序就是操作系统中运行的一个任务,也就是我们所说的“进程”。
例:windows任务管理器
在这里插入图片描述
在一个操作系统中可以同时运行多个进程,就是程序的并发。

CPU时间片:
一个系统同时开了多个程序,就意味着有四个进程要同时运行,为了解决这个问题,计算机规定:让这四个进程轮流使用CPU,每个进程运行一小会。运行的时间被称为一个“CPU时间片”。

通过CPU时间片来看,每一个时间片只有一个程序在运行

CPU执行多任务的原理:
利用很短的cpu时间片运行程序,在多个程序之间进行cpu时间片的进行快速切换。因此就有了一个从宏观上看系统同时运行多个程序的效果

总结:宏观上并行,微观上串行。
从宏观上来看,一个系统同时运行多个程序,这些程序是并行的;
从微观上来说,每个时刻只有一个程序运行,这些程序是串行的。

1.2. 线程的概念

java语言没有多进程的概念:
Java代码运行在JVM中的,对于某个操作系统来说,一个JVM就相当于一个进程。而java代码不能够越过JVM直接与操作系统打交道。

Java中的并发,采用的是线程的概念。
一个操作系统可以并发多个进程,一个进程可以并发多个线程

主线程“main”:

java程序只有一个线程,也就是只有一个程序执行流程,从main方法的第一行开始执行,以main方法退出作为结束

main()方法并非手护线程,不会影响其他线程的运行

线程运行

线程运行需要CPU,只有获得了CPU之后,线程才能真正启动并且执行程序的代码。CPU的调度工作是由操作系统来完成的。

运行线程时,线程必须获得数据

1.3. 堆空间共享,栈空间独立
堆空间:

所谓的“堆空间”,保存的时我们利用new关键字创建出来的对象

栈空间

栈空间保存的是程序运行时的局部变量。

"堆空间独立,栈空间共享"指的是多线程之间共享同一个堆空间,而每个线程又拥有各自独立的栈空间。

注意

程序运行时,多个不同的线程能访问相同的对象,但是多个不同线程彼此之间的局部变量是独立的,不可能出现多个线程访问同一个局部变量的情况。

总结:
CPU、代码、数据,是线程运行时所需要的三大条件。其中CPU是操作系统分配的,Java程序员无法控制;数据需要把握“堆空间独立,栈空间共享”的概念。

2. Thread 类与 Runnable 接口
在java中,要为线程赋予代码,有两种方式:一种是继承 Thread 类,一种是实现 Runnable 接口。
2.1 Thread
创建一个类,继承java.lang.Thead 类,然后覆盖Thread类中的run()方法,在run()方法中提供线程的代码。
Thread 类中 run() 的方法签名:

public void run();

例::一个线程输出 100 遍“ $$$”;另一个线程输出 100 遍“###”。

public class MyThread1 extends Thread{
    Thread t;
    @Override
    public void run() {

        for (int i =1; i <= 100; i++){
            System.out.println(i+"$$$");
        }

    }
}


public class MyThread2 extends Thread {
    Thread t;
    @Override
    public void run() {
        for (int i =1; i <= 100; i++){
            System.out.println(i+"@@@@@@");
        }
    }
}


public class TestThread {
    public static void main(String[] args) {
        Thread t1 = new MyThread1();
        Runnable thread = new MyThread2();
        Thread ts = new Thread(thread);
        t1.start();
        ts.start();
    }
}

利用自定义的线程类创建线程对象后,调用线程的start(),就能启动新线程了

注意:
我们创建线程时,覆盖时run(),而不要去覆盖start()。

调用完 start() 后线程就启动了,在 start() 内部会通过JVM跟底层的操作系统进行交互,请求创建一个新线程。创建完新线后,这个线程执行的就是run()中的代码。

简单的来说就是在start()内部,调用了我们覆盖以后的run()。

为什么不能直接调用run()方法?

因为程序只有一个入口,main()(主线程)。主线程运行了 run() 方法

2.2 Runnable 接口
Runnable接口在java.lang中定义,Runnable接口只包含一个方法:run()方法。
方法签名:
void run();
例:

public class MyThread1 implements Runnable{
    @Override
    public void run() {
        for (int i =1; i <= 100; i++){
            System.out.println(i+"$$$");
        }
    }
}

public class MyThread2 implements Runnable {
    @Override
    public void run() {
        for (int i =1; i <= 100; i++){
            System.out.println(i+"@@@@@@");
        }
    }
}

public class TestThread {
    public static void main(String[] args) {
        Runnable thread1 = new MyThread1();
        Thread t1 = new Thread(thread1);
        Runnable thread2 = new MyThread2();
        Thread ts = new Thread(thread2);
        t1.start();
        ts.start();
    }
}

3.1线程的状态
上面线程的是同步的线程,共有四种状态:初四状态,可运行状态,运行状态,终止状态,如下图:
在这里插入图片描述

初始状态:

当我们创建了一个线程,但是没有调用这个线程对象的start()方法时,此时处于初始状态。调用后由初始状态进入可运行状态。

可运行状态:

线程做好了可以运行的准备,只等CPU来运行

运行状态:

处于这种状态的线程获得了CPU的时间片,正在执行代码,由于系统只有一个CPU,因此同一时间片只有只有同一个线程处于运行状态。

终止状态:

当一个线程执行完了run()方法中的代码,就会进入终止状态,一旦进入了终止状态,就无法通过任何手段重启这个线程。

重点

主方法(mian)退出时,程序未必推出,只有整个程序中所有线程进入终止状态,整个程序才会退出

思考:
为什么对一个线程调用 start()方法之后,这个线程不能马上进 入运行状态,而一定要首先进入可运行状态呢?

  1. 因为运行状态同一时间只能有一个线程运行,而程序只有只有一个入口,就意味着主线程(main)正处于运行状态,运行状态已经有一个线程运行了,所以被启动的新线程只 能先处于可运行状态。
  2. 启动线程时,必须有别的线程调用start方法,而调用start()方法又意味着运行状态有别的线程正在运行,所以只能处于可运行状态了。

举个有味儿的例子,就好比说公共厕所只有一个卫生间(时间片),已经有一人拿到了时间片正在上则所,后面那人纸已经准备好了(可运行状态),前面那人还没出来(终止状态),后面那人只能进入可运行状态等前面那哥们儿出来(终止状态),才能拿到时间片上厕所。(当然你要说他们时gay,我就没话可说了)

3.2 sleep() 与阻塞
除了上面所说的四种状态之外,还有一个很重要的状态:阻塞状态。
阻塞状态:
如果一个线程要进入运行状态,则必须要获得 CPU 时 间片。但是,在某些情况下,线程运行不仅需要 CPU 进行运算,还需要一些别的条件,例 如等待用户输入、等待网络传输完成,等等。如果线程正在等待其他的条件完成,在这种情 况下,即使线程获得了 CPU 时间片,也无法真正运行。因此,在这种情况下,为了能够不 让这些线程白白占用 CPU 的时间,会让这些线程会进入阻塞状态。种情况下,为了能够不 让这些线程白白占用 CPU 的时间,会让这些线程会进入阻塞状态。最典型的例子就是等待 I/O,也就是说,如果线程需要与 JVM 外部进行数据交互的话(例如等待用户输入、读写文 件、网络传输等),这个时候,当数据传输没有完成时,线程即使获得 CPU 也无法运行,因 此就会进入阻塞状态。
例:
可以这么来理解:我们可以把 CPU 当做是银行的柜员,而去银行的准备办理业务的顾 客就好比是线程。顾客希望能够让银行的柜员为自己办事,就好像线程希望获得 CPU 来运 行线程代码一样。但有时会出现这种情况:比如有个顾客想去银行汇款,排队等到了银行的 职员为自己服务,却事先没有填好汇款单。这个时候,这个银行职员即使想为你服务,也无 法做到;这就相当于线程没有完成 I/O,此时线程即使获得 CPU 也无法运行。一般这个时候, 银行的工作人员也不会傻等,而是会非常礼貌的给顾客一张单子,让他去旁边找个地方填写 汇款单,而职员自己则开始接待下一位顾客;这也就相当于让一个线程进入阻塞状态。
当所等待的条件完成之后,处于阻塞状态的线程就会进入可运行状态,等待着操作系统 为自己分配时间片。还是用我们银行职员的比喻:当顾客填写汇款单的时候,就好比是线程 进入了阻塞状态;而当顾客把汇款单填完了之后,这就好比是线程的 I/O 完成,所等待的条 件已经满足了。这样,这个顾客就做好了办理业务所需要的准备,就等着能够有一个银行职 员来为自己服务了。
那为什么从阻塞状态出来之后,线程不能马上进入运行状态呢?因为当线程所等待的条 件完成的时候,可能有一个线程正处于运行状态。由于处于运行状态的线程只能有一个,因 此当线程从阻塞状态出来之后,只能在可运行状态中暂时等待。这就好比,当顾客填完汇款 单之后,银行的职员可能正在为其他顾客服务。这个时候,你不能把其他用户赶走,强行让 银行职员为你服务,而只能重新在后面排队,等着有银行职员为你服务。
如图:

在这里插入图片描述
如上图所示,除了等待 I/O 会进入阻塞状态之外,还可以调用 Thread 类的 sleep 方法进 入阻塞状态。顾名思义,sleep 方法就是让线程进入“睡眠”状态。你可以想象,如果一个 顾客在银行办理业务的时候睡着了,那样的话,银行职员会让这个顾客在一旁先待着,等什 么时候顾客睡醒了,再什么时候为这个顾客服务。
sleep方法签名:
public static void sleep(long millis) throws InterruptedException

这个方法是一个 static 方法,也就意味着可以通过类名来直接调用这个方法。sleep 方 法的参数是一个 long 类型,表示要让这个线程“睡”多少个毫秒(注意,1 秒=1000 毫秒)。 另外,这个方法抛出一个 InterruptedException 异常,这个异常是一个已检查异常,根据异 常处理的规则,这个异常必须要处理。

public class MyThread1 implements Runnable{
    @Override
    public void run() {
        for (int i =1; i <= 100; i++){
            System.out.println(i+"$$$");
            try{
              Thread.sleep(200); 
            }catch(InterruptedException e){
                
            }
        }
    }
}

这个多次运行的话,可以发现,t1 和 t2 两个线程的交替输出不是绝对的,也就是说,利用 sleep 方法对线程的控制是非常不精确的。试图用 sleep 方法严格的要 求线程间进行交替输出,是非常的不可靠的。

3.3 join()
除了使用 sleep()和等待 IO 之外,还有一个方法会导致线程阻塞,这就是线程的 join() 方法。
1.0 无参join


public class MyThread1 extends Thread{
    @Override
    public void run() {
        for (int i =1; i <= 100; i++){
            System.out.println(i+"$$$");
        }

    }
}

public class MyThread2 extends Thread {
    Thread t;
    @Override
    public void run() {
        try {
            t.join();
        } catch (Exception e) {

        }
        for (int i =1; i <= 100; i++){
            System.out.println(i+"@@@@@@");
        }
    }
}

public class TestJoin {
    public static void main(String[] args) {
        MyThread1 t1  =new MyThread1();
        MyThread2 t2 = new MyThread2();
        t2.t = t1;
        t1.start();
        t2.start();
    }
}

2.有参join(),用来解决两个线程同时调用join()互相等待的问题,参数为毫秒

public class MyThread1 extends Thread{
    Thread t;
    @Override
    public void run() {
      try {
          t.join(1000);
      } catch (InterruptedException e) {
          e.printStackTrace();
      }
        for (int i =1; i <= 100; i++){
            System.out.println(i+"$$$");
        }
    }
}

public class MyThread2 extends Thread {
    Thread t;
    @Override
    public void run() {
        try {
            t.join();
        } catch (Exception e) {

        }
        for (int i =1; i <= 100; i++){
            System.out.println(i+"@@@@@@");
        }
    }
}

public class TestThread {
    public static void main(String[] args) {
        MyThread1 t1 = new MyThread1();
        MyThread2 t2 = new MyThread2();

        t1.t = t2;
        t2.t = t1;

        t1.start();
        t2.start();
    }
}

4. 线程同步
4.1 临界资源与数据不一致
我们首先来看一个例子。我们使用数组来实现一个“栈”的数据结构。
栈在表示上,就如同一个单向开口的盒子,每当有新数据进入时,都是进入栈顶。其基 本操作为 push 和 pop。push 表示把一个元素加入栈顶,pop 表示把栈顶元素弹出。
在这里插入图片描述

public class MyStack {
    char[] data = {'A','B',' '};
    int index = 2;
    public void push(char ch){
            data[index] = ch;
            try {
                Thread.sleep(1000);
            } catch (Exception e) {
                e.printStackTrace();
            }
            index++;

    }
    public void pop(){

        index--;
        data[index] = ' ';

    }
    public void print(){
        for(int i = 0; i < data.length; i++){
            System.out.println(data[i] + "\t");
        }
        System.out.println();
    }
}


public class PopThread extends  Thread {
   MyStack ms;
   public PopThread(MyStack ms){
       this.ms = ms;
   }
    @Override
    public void run() {
        ms.pop();
        ms.print();
    }
}

public class PushThread extends  Thread {
    MyStack ms;
    public PushThread(MyStack ms){
        this.ms = ms;
    }
    @Override
    public void run() {
        ms.push('C');
        ms.print();
    }
}

public class TestMyStack {
    public static void main(String[] args) {
        MyStack ms = new MyStack();
        Thread t1 = new PushThread(ms);
        Thread t2 = new PopThread(ms);

        t1.start();
        t2.start();
    }
}

结果为:
A C
A C

:我们的栈要求后入先出,栈内原有两个元素 A、B,结果先启 动了 Push 线程,后启动 Pop 线程之后,却把 B 元素 pop 出去,而把 C 元素保留了。更关键 的是,C 元素和 A 元素中有一个空位,也就是说,栈内的元素变得不连续了!

下图说明了元素入栈和出栈的正确情况:
在这里插入图片描述
而下图则揭示了出现错误的数组情况:
在这里插入图片描述
造成这种错误的原因是什么呢?
第一个原因:
t1 线程中的两个步骤:
1、修改 data 数组;
2、index 变量加 1。
这两个步骤当一起完成,才能组成一个完整的 push()操作;如果这两个 步骤中只完成了一个,就会产生问题。具体来说,就会产生数据不一致的问题,对于 MyStack来说,所谓的数据不一致,指的就是 data 数组中元素的个数和 index 所表示的元素的个数不 一致。
第二个原因,则是因为有 t1和 t2 两个线程,这两个线程并发访问同一个 MyStack 对象。 由于当 t1 线程没有完成的操作的时候,t2 线程就开始对 MyStack 对象进行了访问,从而就 会造成数据不一致。
总结一下上面的两个原因:多个线程并发访问同一个对象,如果破坏了不可分割的操作, 则有可能产生数据不一致的情况。 这其中,有两个专有名词:
被多个线程并发访问的对象,也被称之为“临界资源”;而 不可分割的操作,也被称之为“原子操作”。产生的数据不一致的问题,也被称之为“同步” 问题。要产生同步问题,多线程访问“临界资源”,破坏了“原子操作”,这两个条件缺一不 可
4.2 synchronized
用法的语法如下:
synchronized(obj){
代码块…
}

例:

public class MyStack {
    char[] data = {'A','B',' '};
    int index = 2;
    private Object lock = new Object();
    public void push(char ch){
    synchronized(lock){
            data[index] = ch;
            try {
                Thread.sleep(1000);
            } catch (Exception e) {
                e.printStackTrace();
            }
            index++;
            }
    }
    public void pop(){
        index--;
        data[index] = ' ';
    }
    public void print(){
        for(int i = 0; i < data.length; i++){
            System.out.println(data[i] + "\t");
        }
        System.out.println();
    }
}

加入了锁机制之后的数组情况如下图:
在这里插入图片描述
4.2.3 同步方法
在上面的例子中,我们专门创建了一个 Object 类型的 lock对象,用来在 pop方法和 push 方法中加锁。然而,对于上面的程序来说,除了 lock 对象有锁标记之外,MyStack 对象本身, 也具有互斥锁标记。对于 pop 方法和 push 方法来说,也能够对 MyStack 对象(也就是所谓 的“当前对象”)加锁。
例:

public class MyStack {
    char[] data = {'A','B',' '};
    int index = 2;
    public void push(char ch){
    synchronized(this){
            data[index] = ch;
            try {
                Thread.sleep(1000);
            } catch (Exception e) {
                e.printStackTrace();
            }
            index++;
            }
    }
    public void pop(){
       synchronized(this){
           index--;
           data[index] = ' ';
        }
    }
    public void print(){
        for(int i = 0; i < data.length; i++){
            System.out.println(data[i] + "\t");
        }
        System.out.println();
    }
}

上面的 MyStack 代码可以等价的改为下面的情况:

public class MyStack {
    char[] data = {'A','B',' '};
    int index = 2;
    public synchronized void push(char ch){
            data[index] = ch;
            try {
                Thread.sleep(1000);
            } catch (Exception e) {
                e.printStackTrace();
            }
            index++;
    }
    public synchronized void pop(){
           index--;
           data[index] = ' ';
    }
    public void print(){
        for(int i = 0; i < data.length; i++){
            System.out.println(data[i] + "\t");
        }
        System.out.println();
    }
}
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值