JAVA多线程(上)

本文介绍了线程的概念、创建与启动、Thread类的常用方法,探讨了线程安全问题、线程状态与控制,以及多线程案例如单例模式和阻塞队列。重点讲解了synchronized、Lock接口、Volatile和wait/notify机制。
摘要由CSDN通过智能技术生成

1. 线程

一个线程就是一个 "执行流". 每个线程之间都可以按照顺讯执行自己的代码. 多个线程之间 "同时" 执行着多份代码。

1.1 线程的提出

并发编程能更充分利用多核 CPU 资源;
让等待 IO 的时间能够去做一些其他的工作;
虽然多进程也能实现 并发编程 , 但是线程比进程更轻量。

1.2 创建并启动一个线程

1.准备让线程去执行的工作

        继承Thread类,重写run方法

class MyThread extends Thread { 
    @Override 
    public void run() { 
        System.out.println("这里是线程运行的代码"); 
    } 
}

        实现Runnable接口,重写run方法

class MyRunnable implements Runnable { 
    @Override 
    public void run() { 
        System.out.println("这里是线程运行的代码"); 
    } 
}


2.创建线程实例:构造一个Thread(包括其子类)的对象——我们控制线程的handle
        new Thread子类

MyThread t = new MyThread();

        new Runnable实现类;使用Runnable对象构造Thread对象

Thread t = new Thread(new MyRunnable());


3.启动线程
        

t.start(); // 线程开始运行


        理解start()做了什么:把线程加入到就绪队列中,等待被调度器选中才能执行

        调用 start 方法, 才真的在操作系统的底层创建出一个线程

4.随机性
由于调度的随机,导致多线程的程序,结果可能是随机的,某些语句的执行顺序可能是随机的PS:一个线程自己内部是没有这些问题的

对比上面两种方法 :
        继承 Thread 类 , 直接使用 this 就表示当前线程对象的引用 .
        实现 Runnable 接口 , this 表示的是 MyRunnable 的引用 . 需要使用 Thread.currentThread()

2. Thread类及常见方法

2.1 Thread 的常见构造方法

2.2 Thread 的几个常见属性

  • ID 是线程的唯一标识,不同线程不会重复
  • 名称是各种调试工具用到
  • 状态表示线程当前所处的一个情况,下面我们会进一步说明
  • 优先级高的线程理论上来说更容易被调度到
  • 关于后台线程,需要记住一点:JVM会在一个进程的所有非后台线程结束后,才会结束运行。
  • 是否存活,即简单的理解,为 run 方法是否运行结束了

2.3 等待一个线程-join()

        等待一个线程完成它的工作后,才能进行自己的下一步工作

 2.4 获取当前线程引用

2.5 休眠当前线程

线程的调度是不可控的,所以这个方法只能保证实际休眠时间是大于等于参数设置的休眠时间的。

 2.6 中断一个线程

     1. 通过共享的标记来进行沟通
         使用自定义的变量来作为标志位,需要给标志位上加 volatile 关键字
     2. 调用 interrupt() 方法来通知

thread 收到通知的方式有两种:
1. 如果线程因为调用 wait/join/sleep 等方法而阻塞挂起,则以 InterruptedException 异常的形式通
知, 清除中断标志
     当出现 InterruptedException 的时候 , 要不要结束线程取决于 catch 中代码的写法 . 可以选择
忽略这个异常 , 也可以跳出循环结束线程。
2. 否则,只是内部的一个中断标志被设置, thread 可以通过 Thread.interrupted() 判断当前线程的中断标志被设置, 清除中断标志;
Thread.currentThread().isInterrupted() 判断指定线程的中断标志被设置, 不清除中断标志
这种方式通知收到的更及时,即使线程正在 sleep 也可以马上收到。
3.观察标志位是否清除
标志位是否清除, 就类似于一个开关.
Thread.isInterrupted() 相当于按下开关, 开关自动弹起来了. 这个称为 "清除标志位"
Thread.currentThread().isInterrupted() 相当于按下开关之后, 开关弹不起来, 这个称为 "不
清除标志位".

2.7 相关问题

1.多核环境下,并发排序的耗时<串行排序的耗时(我们现在看到的现象)
   单线程一定能跑在一个CPU(核)上,多线程可能工作在多个核上(核亲和性)
   OS调度的单位是线程,但我们衡量耗时是以进程为单位的。

2.单核环境下,并发排序的耗时也能小么?

   即使在单核环境下,并发的耗时也可能较少
   本身,计算机下就有很多线程在等待分配CPU,比如,现在有100个线程,意味公平的情况下,我们的排序主线程,只会被分配1/100的时间。当并发时,我们使用4个线程分别排序,除其他的99个之外,计算机中共有99+4=103个线程我们4个线程同属于一个进程,分给我们进程的时间占比4/103>1/100。
   所以,即使单核情况下,我们一个进程中的线程越多,被分到的时间片是越多的

3. 那线程越多越好么?

    不是的。
    (1)创建线程本身也不是白嫖的。
    (2)即使理想情况下,不考虑其他耗时,极限也就是100%线程调度也需要耗时(OS从99个线程中挑一个的耗时和从9999个线程中挑一个的耗时不同)CPU是公共资源,写程序的时候也是要考虑公德心的。如果是好的OS系统,可能也会避免这个问题。

4.并发排序的耗时就一定小于串行的么?

   不一定。
   串行的排序:t=t(排区间1)+t(排区间2)+t(排区间3)+t(排区间4)
   并发的排序:t=4*t(创建线程)+t(排区间1)+t(排区间2)→((排区间3)+t(排区间4)+4*t(销毁)
为什么我们要写多线程的代码的原因之一:提升整个进程的执行速度(尤其计算密集性的程序)

3.线程的状态

   NEW: 安排了工作 , 还未开始行动
   RUNNABLE: 可工作的 . 又可以分成正在工作中和即将开始工作 .
   BLOCKED: 这几个都表示排队等着其他事情
   WAITING: 这几个都表示排队等着其他事情
   TIMED_WAITING: 这几个都表示排队等着其他事情
   TERMINATED: 工作完成了

3.1 线程让出CPU-Thread.yield()

线程让出CPU,线程就从运行状态转变为就绪状态,yield 不改变线程的状态, 但是会重新去排队.

yield主要用于执行一些耗时较久的计算任务时,为防止计算机处于卡顿的现象,是不是让出一些CPU资源,给OS内的其他进程。

让出CPU,会引导OS进行新的一轮线程调度,以后等继续分配CPU时,恢复之前保存的CPU。

4. 线程安全

4.1 线程为什么会不安全

     修改多个线程都能访问的共享数据

  •  原子性:不保证线程的原子性的话,线程在操作变量过程中被打断就容易产生错误结果
  •  可见性:线程对共享变量的修改,能及时被其他线程看到

     CPU中,为了提升获取数据速度,设置了“工作内存”——CPU的寄存器和高速缓存(Cache)
     CPU访问自身寄存器速度>访问高速缓存>访问内存

  •  代码重排序

4.2 作为程序员如何考虑线程安全性

        1.尽可能让多个线程之间不做数据共享,各干各的;
        2.如果有共享操作,尽可能不做数据修改,而是只读;
        3.一定会出现线程安全了,从系统角度分析:
           原子性被破坏了;
           由于内存可见性问题,导致某些线程读到“脏数据”;
           由于代码重排序导致线程之间数据的配合出现了问题。

之前学习过的:ArrayList、LinkedList、PriorityQueue、TreeMap、TreeSet、HashMap、HashSet、StringBuilder等都不是线程安全的。
线程安全的:Vector、Stack、Dictionary、StringBufferf(编程中尽量避免使用)

4.3 解决线程不安全问题

4.3.1 synchronized 关键字

语法:
1.修饰普通方法==》 视为对“当前对象”加锁(使用哪个对象调用的这个同步方法)
    synchronized (this){...}

2.修饰静态方法==》视为对静态方法所在的类加锁                                                                                synchronized (类.class){...}
3.同步代码块
    synchronized(引用){...}

     锁(Lock):锁理论上就是一段数据(一段被多个线程之间共享的数据)
         一个线程先上了锁,其他线程只能等待这个线程释放

     sync 请求锁:                                                                                                                                         1.请求成功(该锁。没有线程持有)继续执行大括号内的代码                                                           2.请求失败(该锁,已经被线程持有)请求锁的线程会阻塞,直到锁被释放后,重新去请求锁

        理解 "阻塞等待":
        针对每一把锁, 操作系统内部都维护了一个等待队列. 当这个锁被某个线程占有的时候, 其他线程尝试进行加锁, 就加不上了, 就会阻塞等待, 一直等到之前的线程解锁之后, 由操作系统唤醒一个新的线程, 再来获取到这个锁。
        注意:
        上一个线程解锁之后, 下一个线程并不是立即就能获取到锁. 而是要靠操作系统来 "唤醒". 这也
就是操作系统线程调度的一部分工作.假设有 A B C 三个线程, 线程 A 先获取到锁, 然后 B 尝试获取锁, 然后 C 再尝试获取锁, 此时 B和 C 都在阻塞队列中排队等待. 但是当 A 释放锁之后, 虽然 B 比 C 先来的, 但是 B 不一定就能获取到锁, 而是和 C 重新竞争, 并不遵守先来后到的规则。

         在使用sync时,实际上隐含一个解引用操作(通过引用操作引用指向的对象),sync(ref){...} 当ref == null 的时候,一定会有NullPointerException

互斥:
        synchronized 会起到互斥效果, 某个线程执行到某个对象的 synchronized 中时, 其他线程如果也执行到同一个对象 synchronized 就会阻塞等待。

        互斥的必要条件:线程都有加锁操作 ,同一把锁锁的是同一个对象.也就是说,两个线程竞争同一把锁, 才会产生阻塞等待。两个线程分别尝试获取两把不同的锁, 不会产生竞争。

        进入 synchronized 修饰的代码块, 相当于加锁;退出 synchronized 修饰的代码块, 相当于解锁。

        在加锁时,只会有一个线程可以持有锁(加锁成功),其余加锁失败的线程都会:
        1.进入该锁的阻塞队列(等待队列)
        2.放弃CPU

sync的作用

        1.sync主要保证原子性:通过正确地加锁使得应该原子性的代码之间互斥来实现

        2.sync在有限程度上可以保证内存可见性                                                                                            加锁时,在加锁成功之前,清空当前线程的工作内存                                                                        在临界区代码中,读到某些变量时(主内存上的数据)保证读到的是最新的数据(临界区期间的数据读写不做保证)                                                                                                                              解锁时,保证把工作内存中的数据全部同步回主内存

        3.sync也可以给代码重排序增加一定的约束

4.3.2 juc包下的锁

  java.util.concurrent.locks.Lock;

ReentrantLock 重入锁(同时支持公平与非公平的选择)

Lock lock = new ReentrantLock(); //这里可以是自己实现Lock接口的实现类,也可以是jdk提供的同步组件
lock.lock();//一般不将锁的获取放在try语句块中,因为如果发生异常,在抛出异常的同时,也会导致锁的无故释放
try {
    //临界区代码
}finally {
     lock.unlock(); //放在finally代码块中,保证锁一定会被释放
}

lock.tryLock(long time, TimeUnit unit)
java中方法的结束以两种形式出现:
1.正常结束,并返回
2.异常结束,抛出异常
   方法结束:1.超时时间内(time),加锁成功,正常返回true
        2.超时时间到了(time),加锁失败,正常返回false
        3.超时时间内(time),加锁还没成功但是线程被终止了,异常返回,捕获到InterruptedException 异常

t.interrupt() : 让线程停止
  t.interrupt() 的意思是发消息告诉t线程,应该结束了,至于t线程能不能收到这个消息,得看t当时在干啥,如果t在执行 lock.lock() 就没法获得消息

4.3.3 Lock接口 VS synchronized内置锁

1.synchronized:Java提供的内置锁机制,Java中的每个对象都可以用作一个实现同步的锁(内置锁或者监视器Monitor),线程在进入同步代码块之前需要或者这把锁,在退出同步代码块会释放锁。而synchronized这种内置锁实际上是互斥的,即每把锁最多只能由一个线程持有。

2.Lock接口:Lock接口提供了与synchronized相似的同步功能,和synchronized(隐式的获取和释放锁,主要体现在线程进入同步代码块之前需要获取锁退出同步代码块需要释放锁)不同的是,Lock在使用的时候是显示的获取和释放锁。虽然Lock接口缺少了synchronized隐式获取释放锁的便捷性,但是对于锁的操作具有更强的可操作性、可控制性以及提供可中断操作和超时获取锁等机制。

5. volatile 关键字

        volatile 能保证内存可见性, 加上 volatile , 强制读写内存. 速度是慢了, 但是数据变的更准确了,volatile一定程度上保证代码重排序,不保证原子性。
static class Counter { 
    public volatile int flag = 0; 
}
代码在写入 volatile 修饰的变量的时候:
        改变线程工作内存中volatile 变量副本的值;
        将改变后的副本的值从工作内存刷新到主内存。
代码在读取 volatile 修饰的变量的时候:
        从主内存中读取volatile 变量的最新值到线程的工作内存中;
        从工作内存中读取volatile 变量的副本。

6. wait notify

        线程之间是抢占式执行的, 开发中需要 合理的协调多个线程之间的执行先后顺序。主要涉及到三个Object方法:
        wait() / wait(long timeout): 让当前线程进入等待状态
        notify() / notifyAll(): 唤醒在当前对象上等待的线程

6.1 wait()方法

wait 做的事情 :
  • 使当前执行代码的线程进行等待. (把线程放到等待队列中)
  • 释放当前的锁
  • 满足一定条件时被唤醒, 重新尝试获取这个锁.                                                                       wait 要搭配 synchronized 来使用. 脱离 synchronized 使用 wait 会直接抛出异常.
wait 结束等待的条件 :
  • 其他线程调用该对象的 notify 方法.
  • wait 等待时间超时 (wait 方法提供一个带有 timeout 参数的版本, 来指定等待时间).
  • 其他线程调用该等待线程的 interrupted 方法, 导致 wait 抛出 InterruptedException 异常.  
public static void main(String[] args) throws InterruptedException { 
    Object object = new Object(); 
    synchronized (object) { 
        System.out.println("等待中"); 
        object.wait(); 
        System.out.println("等待结束"); 
    } 
}
        wait 和 sleep 的对比(面试题)
        其实理论上 wait sleep 完全是没有可比性的,因为一个是用于线程之间的通信的,一个是让线程阻塞 一段时间,唯一的相同点就是都可以让线程放弃执行一段时间.
        1. wait 需要搭配 synchronized 使用 . sleep 不需要 .
        2. wait 是 Object 的方法 sleep Thread 的静态方法 .

6.2 notify()方法

notify 方法是唤醒等待的线程 .
  • 方法notify()也要在同步方法或同步块中调用,该方法是用来通知那些可能等待该对象的对象锁的其它线程,对其发出通知notify,并使它们重新获取该对象的对象锁。
  • 如果有多个线程等待,则有线程调度器随机挑选出一个呈 wait 状态的线程,并没有 "先来后到"。
  • notify()方法后,当前线程不会马上释放该对象锁,要等到执行notify()方法的线程将程序执行 完,也就是退出同步代码块之后才会释放对象锁。
  • 先notify后wait没有用

6.3 notifyAll()方法

notify 方法只是唤醒某一个等待线程, 使用 notifyAll 方法可以一次唤醒所有的等待线程,唤醒所有的线程只是说这些线程需要重新竞争锁

7. 多线程案例

设计模式(design pattern):对一些解决通用问题的、经常书写的代码片段的总结与归纳

7.1 单例模式(singleton pattern)

单例模式保护一个类,保证某个类在整个进程运行过程中只存在唯一一份实例 , 而不会创建出多个实例

7.1.1 饿汉模式:类创建的同时直接初始化实例

class Singleton { 
    private static Singleton instance = new Singleton(); 
    private Singleton() { } 
    public static Singleton getInstance() { 
        return instance; 
    } 
}

7.1.2 懒汉模式:类加载的时候不创建实例. 等到使用的时候才创建实例.

class Singleton { 
    private static volatile Singleton instance = null; 
    private Singleton() {} 
    public static Singleton getInstance() { 
        //双重if判定,降低竞争锁的频率
        if (instance == null) { 
        //只有instance还没有初始化时,才会走到这个分支
        //这里没有锁的保护,所以理论上有很多线程可以同时走到这个分支
            synchronized (Singleton.class) { 
                //通过上面的条件,让争抢锁的动作只发生在instance实例化之前
                //加锁之后才能执行,只有第一个抢到锁的线程看到的instance才是null
                //保证了instance只会被实例化一次
                if (instance == null) { 
                    instance = new Singleton(); //只在第一次的时候执行
                } 
            } 
        }
        return instance; 
    } 
}
理解双重 if 判定 / volatile:
        加锁 / 解锁是一件开销比较高的事情. 而懒汉模式的线程不安全只是发生在首次创建实例的时候. 因 此后续使用的时候, 不必再进行加锁了.
        外层的 if 就是判定下看当前是否已经把 instance 实例创建出来了.
        同时为了避免 "内存可见性" 导致读取的 instance 出现偏差, 于是补充上 volatile
        当多线程首次调用 getInstance, 大家可能都发现 instance 为 null, 于是又继续往下执行来竞争锁, 其中竞争成功的线程, 再完成创建实例的操作.
        当这个实例创建完了之后, 其他竞争到锁的线程就被里层 if 挡住了. 也就不会继续创建其他实例.

7.2 阻塞队列(blocking queue)

阻塞队列是一种特殊的队列 . 也遵守 " 先进先出 " 的原则 .
阻塞队列能是一种线程安全的数据结构 , 并且具有以下特性 :
  • 当队列满的时候, 继续入队列就会阻塞, 直到有其他线程从队列中取走元素.
  • 当队列空的时候, 继续出队列也会阻塞, 直到有其他线程往队列中插入元素.
阻塞队列的一个典型应用场景就是 " 生产者消费者模型": 生产者和消费者彼此之间不直接通讯,而通过阻塞队列来进行通讯,所以生产者生产完数据之后不用等 待消费者处理,直接扔给阻塞队列,消费者不找生产者要数据,而是直接从阻塞队列里取。
标准库中的阻塞队列
Java 标准库中内置了阻塞队列 . 如果我们需要在一些程序中使用阻塞队列 , 直接使用标准库中的即可 .
  • BlockingQueue 是一个接口. 真正实现的类是 LinkedBlockingQueue.
  • put 方法用于阻塞式的入队列, take 用于阻塞式的出队列.
  • BlockingQueue 也有 offer, poll, peek 等方法, 但是这些方法不带有阻塞特性.

 失败时抛出InterruptedException异常

BlockingQueue<String> queue = new LinkedBlockingQueue<>(); 
// 入队列 
queue.put("abc"); 
// 出队列. 如果没有 put 直接 take, 就会阻塞. 
String elem = queue.take();
阻塞队列实现
通过 " 循环队列 " 的方式来实现 .
  • 使用 synchronized 进行加锁控制.
  • put 插入元素的时候, 判定如果队列满了, 就进行 wait. (注意, 要在循环中进行 wait. 被唤醒时不一定队列就不满了, 因为同时可能是唤醒了多个线程).
  • take 取出元素的时候, 判定如果队列为空, 就进行 wait. (也是循环 wait)
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值