多线程(Java)

发布于:2025-04-12 ⋅ 阅读:(25) ⋅ 点赞:(0)

注:本文为本人学习过程中的笔记

1.导入

1.进程和线程

我们希望我们的程序可以并发执行以提升效率,此时引入了多进程编程。可是创建进程等操作开销太大,于是就将进程进一步拆分成线程,减少开销。进程与进程之间所涉及到的资源是相互独立的,不会相互干扰。至于线程之间具体是怎么调度的,我们很难知道,这主要是操作系统随机调度。

进程是操作系统资源分配的基本单位

线程是操作系统调度执行的基本单位

进程是存在父子关系的,而线程不存在

2.多线程代码的简单写法

1.创建线程

1.重写Thread中的run方法

public class Test{
    public static void main(String[] args){
        Thread t1 = new MyThread;
    }
}
public class MyThread extends Thread{
    public void run() {
        ....
    }
}

2.重写Runnable接口中的run方法 

public class Test implements Runnable{
    public static void main(String[] args){
        Runnable myRunnable = new MyRunnable();
        Thread t1 = new Thread(myRunnable);
    }
}
public class MyRunnable{
    public void run(){
        ...
    }
}

3.使用lamda表达式 

public class Test{
    public static void main(String[] args){
        Thread t1 = new Thread(() -> {
           public void run(){
               ...
            } 
        });
    }
}

2.一些方法

new Thread()

a.Thread()        直接创建线程对象

b.Thread(Runnable target)        使用Runnable对象创建线程对象

c.Thread(String name)        创建线程对象并命名

d.Thread(Runnable target, String name)        使用Runnable对象创建线程对象并命名

run()

run方法是线程的入口方法,进入线程自动就会调用,不需要我们调用

start()

这是启动线程的方法

sleep() 

这个方法要通过Thread类来调用,效果是使当前线程休眠一定时间。在括号里可以设置休眠的时间,单位是毫秒。

Thread.sleep(1000);

使用这个方法时会抛出InterruptedException异常 

因为线程的调度是不可控的,所以这个方法只能保证实际休眠时间大于等于参数设置的时间。代码调用sleep,相当于让当前线程让出cpu资源,后续时间到的时候就需要操作系统内核把这个线程重新调度到cpu上才能继续执行。

sleep(0)是一种特殊写法,意味着让当前线程立即放弃cpu资源等待操作系统重新调度

getId()

Java中会给每个运行的线程分配id,获取id

getName()

获取名字

getState

获取状态

getPriority

获取优先级

isDaemon()

判断是否是后台线程

Java中存在后台线程和前台线程,后台线程随着进程的开启而开启,关闭而关闭,不影响进程的状态,我们创建的线程和main线程是前台线程,可以通过setDaemon方法修改

setDaemon()

在括号里填写true或者false来设置线程是否是后台线程

isAive()

判断线程是否存活

Java代码中创建的Thread对象和系统中的线程是一一对应关系。但是,Thread对象的生命周期和系统中的生命周期是不同的,可能存在Thread对象还存货,但是系统中的线程已经销毁的情况

interrupt()

关闭线程

调用这个方法时,会修改isInterruptted方法内部的标志位将其设为true。如果我们在使用了sleep方法并且唤醒了该方法,那sleep方法就会把isInterruptted的标志位设置为false,这时sleep会抛出Interruptted异常,我们可以修改try-catch语句中的代码,达到我们想要的效果而不是直接关闭线程

isInterruptted()

判断线程是否关闭

Thread.currentThread()

这是一个静态方法,在哪个线程中调用就能获得哪个线程的应用

join()

join能够要求多个线程结束的先后顺序。比如在main线程中调用t.join就会使main线程等待t线程先结束。只要t线程不结束,主线程的join就会一直的等待下去。我们可以在括号里设置最大等待时间,当到达时间join就不会再等待,继续执行下面的代码

wait() / notify()

这两个方法是用来协调线程之间的执行逻辑的顺序。虽然我们不能干预调度器的调度顺序,但是我们可以让后执行的线程进行等待,等到先执行的线程执行完了,通知当前线程,继续执行。

在Java标准库中,每个产生阻塞的方法都会抛出InterrupttedException异常,会被interrupt方法唤醒,wait也是一样

wait和join的区别

join也是等,当join是等另一个线程彻底执行完才继续执行

wait是等另一个线程执行到notify才继续走,不需要等另一个线程执行完

应用场景

当多个线程竞争一把锁的时候,获取锁的线程如果释放了,其他哪个线程能拿到这把锁是不确定,我们不能控制操作系统怎么调度,当我们可以使用wait和notify语句来控制这个顺序

使用方法

使用wait时,线程会释放锁,所以我们要使用wait时线程必须获取锁,否则会报错

wait的等待最关键的一点就是先释放锁,给其他线程获取锁的机会,并且阻塞等待。如果其他线程做完了必要的工作,调用notify唤醒这个wait线程,wait就会解除阻塞,重新获取到锁,然后继续执行

synchronized (locker) {
    locker.wait();
}

synchronized(locker) {
    locker.notify();
}

这其中的锁对象必须时同一个锁对象才能产生效果,如果多个线程在同一个锁对象上wait,进行notify的时候是随机唤醒其中一个线程,一次notify唤醒一个wait。wait也可以在括号填写超时时间,不死等。

wait和sleep的区别

wait有等待时间,可以用notify唤醒。sleep也有等待时间,可以使用interrupt提前唤醒

wait必须要搭配锁使用,先加锁,才能用wait,sleep不需要

如果都是在synchronized内部使用,wait会释放锁,而sleep不会释放锁

notifyAll()

使用这个方法可以一次唤醒所有相关的wait

3.小工具

1.jconsole

这个小工具在jdk的bin目录下,使用这个工具可以连接java程序,从而观察线程的信息

3.线程状态

NEW

安排了工作,还未开始行动

也就是new了Thread对象,还没start

TERMINATED

工作完成了

内核中的线程已经结束了但是Thread对象还在

RUNNABLE

可工作的,又可以分成正在工作中和即将开始工作

a.线程正在cpu上执行

b.线程随时可以去cpu上执行

TIMED_WAITTING

表示排队等着其他事情,有超时时间

当我们调用join方法,线程就会进入这个状态。

WAITING

表示排队等待其他事情,没有超时时间,死等

BLOCKED

表示排队等待其他事情

这个比较特殊,是由于锁导致的阻塞

总图

4.线程安全

一段代码,如果再多线程并发执行的情况下,出现bug,就称为线程不安全

1.线程安全问题产生的原因

Java中的一行代码对应着cpu上的多条指令,并不是原子的,所以当cpu随机调度的时候就可能出现一些问题,也就会产生线程安全问题,以下是产生线程安全问题的原因

a.(根本原因)操作系统对于线程的调度是随机的,抢占式执行

b.多个线程同时修改一个变量

c.修改操作不是原子的

d.内存可见性,jvm会优化代码

e.指令重排序,jvm会优化代码

2.如何解决线程安全问题

a.针对问题a我们是无法解决的,因为抢占式执行是操作系统的底层设定

b.针对问题b,这个问题和代码结构相关,我们可以调整代码结构,规避一些线程不安全的代码。但是这样的方案是不够通用的,有些情况下,需求上就是需要多线程同时修改一个变量

c.针对问题c,我们可以使用加锁操作,这也是Java中解决线程安全问题最主要的方案,通过加锁操作我们可以将不是原子的操作打包成原子的操作。

d.使用volatile关键字

jvm对于我们的代码会自动的进行一些优化,比如说如果我们写出这样的一个语句

public class Test{
    int count = 0;
    public static void main(String[] args) throws InterrupttedException{
        Thread t1 = new Thread(() -> {
            while(count == 0) {

            }
        });
        t1.start();
        Thread.sleep(30000);
        count = 1;
    }
}

当我们启动这个代码时,线程t1中的循环会被一直执行下去,这是为什么呢?明明我们在主线程中都修改了count的值

因为这个时候jvm优化了我们的代码,在t1线程中,while语句不断地从内存中读取count的值,这个操作的开销是比较大的,此时jvm会将count的值保留在cpu寄存器中,直接读取寄存器的count值,这就导致了我们修改了count的值而不会被读取到

此时就可以使用volatile关键字修饰count这个变量让jvm不优化我们的代码

注意:在Java的官方文档中这样写道,每个线程,有一个自己的“工作内存”,同时这些线程共享一个“主内存”。当一个线程循环进行上述读取变量操作的时候,就会把主内存中的数据,拷贝到......

这里提到的“工作内存”就是我们所说的cpu寄存器,cpu上还有缓存,因为Java是跨平台的语言,设计者不希望程序员有学习硬件知识的成本,所以将其抽象为“工作内存的概念”。

e.使用volatile关键字

volatile关键字不仅可以解决内存可见性问题,还可以不让jvm进行指令重排序。

3.锁

加锁/解锁本身是系统提供的api,很多编程语言都对这样的api进行了封装,大多数的封装风格都是采取lock和unlock两个函数,Java中采用的是synchronized关键字

synchronized

synchronized(锁对象) {//进入代码块相当于加锁
    ...
}//离开代码块相当于解锁

括号里需要我们填写加锁的对象,这个锁的类型不重要,重要的是是否有几个线程尝试针对同一个锁对象进行加锁。只有两个线程针对同一个锁对象加锁,才能产生互斥效果。即一个线程获取锁之后,另一个线程为了也能获取锁只能阻塞等待第一个线程中的锁释放出来 

synchronized的变种写法

synchronized可以对方法进行加锁

当要加锁的方法是被static修饰时,synchronized修饰static方法就相当于针对类对象进行加锁

死锁

产生死锁的原因
1.互斥

当两个线程争夺同一个锁时会产生互斥,这是锁的基本特性

2.不可剥夺

当一个线程获得锁之后,这个锁是不能被抢走的,只能等待它释放出来,这也是锁的基本特性

3.请求和保持

当一个线程已经获取一把锁之后,继续请求其他的锁

4.循环等待

当一个线程已经获取一把锁之后,继续请求其他锁,且有另一个线程也在获得锁的情况下请求相同的锁,形成循环

解决死锁的办法

针对问题1和问题2是无法解决的,这是锁的基本特性

针对问题3

我们可以避免锁嵌套

针对问题4

可以约定加锁的顺序,使争夺锁的过程形不成循环

2.Java中线程安全的东西

String

系统api没有提供修改String的方法,导致String天然就是线程安全的

5.多线程代码案例

1.单例模式

单例模式是指在某个类,某个程序中只允许有唯一一个实例,不允许有多个实例,不允许new多次。

1.应用场景

比如我们有一个100G的数据库要加载到内存中方便读取,由于这个数据库数据非常多,创建等操作需要的开销都非常大,此时我们希望只创建一次即可,否则将会产生非常多的额外的开销也可能导致服务器的内存不够用。

2.两种写法

懒汉模式和饿汉模式是存在缺陷的,可以通过反射来创建实例,但是反射本身属于非常规的手段,一般编写代码的时候不使用

1.饿汉模式

饿是指尽早创建实例

1.写法
class SingleTon{
    private static SingleTon instance;//静态成员的初始化是在类加载的时候就触发的,往往程序一启动类就会加载
    public static SingleTon getInstance(){
        return instance;
    }//后续统一使用getInstance这个方法来获取实例
    private SingleTon{
    
    }//由于构造方法是私有的,所以在类外new instance都会编译失败
}
2.线程安全问题

由于饿汉模式中的instance在程序启动的时候就创建好了,所以我们后续的操作都只涉及到读操作,不会产生线程安全问题。 

2.懒汉模式

懒是指尽可能晚地创建实例,延迟创建

1.写法
class SingleTonLazy{
    private static SingleTonLazy instance = null;
    public SingleTonLazy getInstance(){
        if(instance == null){//懒汉模式创建实例的时机是第一次使用的时候而不是程序启动的时候
            instance = new SingleTonLazy();
        }
        return instance;
    }
    private SingleTonLazy{
        
    }
}
2.线程安全问题 

懒汉模式这里getInstance里面给instance赋值这个操作,等号是原子的,但是当它和if语句组合在一起的时候,就变得不是原子的了,此时我们就可以给if语句和内部的赋值语句加锁,让它变成原子的。

class SingleTonLazy{
    Object locker = new Object();
    private static SingleTonLazy instance = null;
    public SingleTonLazy getInstance(){
        synchronized(locker){
            if(instance == null){
                instance = new SingleTonLazy();
            }
            return instance;
        }
    }
    private SingleTonLazy{
        
    }
}

这里加锁之后,我们使用getInstance就是线程安全的了。可是,这又引出了一个新的问题,当我们第一次调用过getInstance语句之后,instance就创建好了,我们之后再调用getInstance都只需要使用return就好,可是我们在这里加了锁,如果有多个线程同时调用这个方法,就会产生阻塞,影响程序的效率。 这个时候,我们就可以再加一个if语句

class SingleTonLazy{
    Object locker = new Object();
    private static SingleTonLazy instance = null;
    public SingleTonLazy getInstance(){
        if(instance == null){//这个if是来判断是否需要加锁
            synchronized(locker){
                if(instance == null){//这个if是来判断是否需要创建instance
                    instance = new SingleTonLazy();
                }
                return instance;
            }
        }
    }
    private SingleTonLazy{
        
    }
}

进行了这么多处理之后,代码依然存在一些问题,instance = new SingleTonLazy()这个操作可能涉及指令重排序问题,jvm可能会更改指令的顺序,new这个操作涉及到申请内存空间,初始化对象,将内存地址赋值给引用变量。如果这个new操作先把内存地址赋值给引用变量,再进行变量初始化的话,这时另一个线程使用getInstance方法时,instance这个实例就已经存在了,可是还没有初始化,这又构成了线程安全问题,此时我们就可以使用volatile关键字来修饰instance,阻止jvm进行指令重排序

class SingleTonLazy{
    Object locker = new Object();
    private static volatile SingleTonLazy instance = null;
    public SingleTonLazy getInstance(){
        if(instance == null){//这个if是来判断是否需要加锁
            synchronized(locker){
                if(instance == null){//这个if是来判断是否需要创建instance
                    instance = new SingleTonLazy();
                }
                return instance;
            }
        }
    }
    private SingleTonLazy{
        
    }
}

这样我们的代码就是线程安全的了。 

2.阻塞队列

阻塞队列其实就是一种更复杂的队列,它是线程安全的

1.特性

a.队列为空时,尝试出队列,出队列操作就会阻塞,阻塞到其他线程添加元素为止

b.队列为满时,尝试入队列,入队列操作也会阻塞,阻塞到其他线程取走元素为止

2.应用场景

1.生产者消费者模型

简单来说就是生产者生产产品,消费者消费产品,他们在一个消费场所进行这些操作,而这个消费场所就是阻塞队列

1.该模型的优点
1.解耦合

这里的解耦合不一定是两个线程之间,也可以是两个服务器之间。

如果是A直接访问B,此时A和B的耦合就会更高。编写A的代码的时候,多多少少会有一些和B相关的逻辑,编写B的代码的时候,也会有一些A的相关逻辑。 

此时添加一个阻塞队列,让A和队列交互,让B和队列交互,这样A和B之间就解耦合了。A和B是业务服务器,所以经常会涉及到改动,而阻塞队列并不会经常修改,所以A,B分别和阻塞队列耦合是没什么问题的。阻塞队列非常重要,有时甚至会把队列单独部署成一个服务,称为“消息队列”。

2.削峰填谷

在实际的应用场景中,服务器接收到的请求并不是稳定的,有时候会很多,有时又很少,当没有阻塞队列,两个服务器直接进行交互时

当A遇到一波流量激增,此时它会把每个请求都转发给B,B也会承担一样的压力,此时就很容易把B给搞挂了。 

一般来说A这种上游的服务器,尤其是入口的服务器,干的活更简单,单个请求消耗的资源较少,而像B这种下游的服务器,通常承担更重的任务量(复杂的计算/存储工作),单个请求消耗的资源更多。

如果有阻塞队列的话,压力就可以由阻塞队列来承担,B可以不关心数据量的多少,按照自己的节奏慢慢处理队列里的数据即可。由于大量的请求一般都是突发的,时间也不长,所以B可以趁着峰值过去了继续消费数据,利用波谷的时间,来消费之前积压的数据。

2.该模型的缺点

1.引入队列之后,整体的结构会更复杂,此时就需要更多的机器进行部署,生产环境的结构也会更加复杂,管理起来更麻烦

2.效率会有影响

3.使用

Java标准库中提供了阻塞队列,我们可以直接使用

1.put()/take()

阻塞队列继承了Queue接口,所以我们可以使用offer()和pull()来存取数据,但是想要达到阻塞效果的话必须使用take()和put()方法。 


网站公告

今日签到

点亮在社区的每一天
去签到