多线程基础

发布于:2024-07-06 ⋅ 阅读:(15) ⋅ 点赞:(0)

多线程基础

一。线程基础概念

在java中不是很鼓励使用多进程编程,而鼓励使用多线程编程

引入多个进程的初心就是为了实现并发编程

但是多进程也是有缺点的:进程太重量,效率不高

进一步理解这句话就是,创建进程的时候,消耗的时间比较多;销毁进程的时候,消耗的时间比较多;调度一个进程的时候,消耗的进程也比较多…

那么什么是消耗时间多呢?

其实,进程就是资源分配的基本单位,内存的分配就是一个大活,因此说其是一个耗时的操作

为了解决上述问题,就引入了**线程(Thread)**这个概念,线程又叫‘轻量化进程’

线程不能独立存在,而是依附于进程,一个进程可以有一个线程,也可以有多个进程

一个进程最开始的时候至少要有一个线程,用来负责执行代码的工作,也可创造多个线程,实现并发编程

线程的特点:

1.每个线程都可以独立的去CPU上调度执行

2.同一个进程里的多个线程公用一份内存空间,文件资源…(创建线程的时候不用再分配资源了,直接调用之前分配给进程的资源即可

总结:

进程是资源分配的基本单位

线程是调度执行的基本单位

一个系统里可以有很多进程,每个进程都有自己的资源

一个进程里可以有多个线程,每个线程都能独立调度,共享内存/硬盘资源

经典面试题:进程与线程的区别:

1.进程包含这线程,进程内可以有一个或一个以上的线程

2.进程之间是存在隔离性的,而线程不是,如果一个线程出现问题,有可能会导致这个进程里的所有线程全部出现异常

3.进程是资源分配的基本单位,线程是调度执行的基本单位

4.同一个进程的线程之间,可以进行资源共享,省去了申请资源的开销

5.进程和线程都是为了并发编程,但是线程比进程更轻量,更高效

二。java中如何进行多线程编程

线程是操作系统的概念,操作系统提供了一系列的API(应用程序接口),可以操作线程

在java中,程序员只需掌握一种API,就可以了

1.继承Thread类,重写run方法即可

class MyThread extends Thread{
  public void run(){
    //这就是这个进程的入口
    System.out.println("hello world");
  }
}

public class Demo1{
  public static void main(String[] args){
    Thread t=new MyThread();
    t.start();
  }
}

start和run都是Thread的成员

run只是描述了线程的入口(线程主要做什么工作的),而start就是真正的调用系统的API,在系统中创建出线程,让线程再调用run

这个就是最简单的创建多线程的方法,MyThread是一个线程,main是一个线程

例子:

class MyThread extends Thread{
  public void run(){
    while(true){
      System.out.println("hello Thread");
    }
  }
}
public class Demo1{
  public static void main(String[] args){
    Thread t=new MyThread();
    t.start();
    while(true){
      System.out.println("hello Main");
    }
  }
}

上述代码就会使MyThread进程与Main进程同时进行

如果这里的t.start()改为t.run() ,那么此时代码中就不会创建出新线程,只有一个主线程,这个主线程里面只能依次执行循环,执行完一个再执行下一个。所以以前写的代码都是单线程代码

可以使用jconsole来查看一个程序所占的线程数

2. sleep方法

当前这俩循环转的太快了,希望它慢一点,则可以在循环体内加入sleep,sleep是Thread的静态方法

package Mode1;
class MyThread extends Thread{
    public void run(){
        while(true){
            System.out.println("hello Thread");
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
        }
    }
}
public class Mode12 {
    public static void main(String[] args) throws InterruptedException {
        Thread t=new MyThread();
        while(true){
            System.out.println("hello Main");
            Thread.sleep(1000);
        }
    }
}

Thread在使用sleep的时候要使用try,catch来避免异常

三。创建线程的其它方法

1.继承Thread,重写run方法:

上面已经介绍

2.实现Runnable,重写run方法:

package Mode1;

class MyRunnable implements Runnable{
    public void run(){
        while(true){
            System.out.println("hello Thread");
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
        }
    }
}
public class Mode12 {
    public static void main(String[] args) {
        Runnable runnable=new MyRunnable();
        Thread t=new Thread(runnable);
        t.start();
        while(true){
            System.out.println("hello Main");
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
        }
    }
}

这里复习一下之前javaSE阶段的多态的知识点,封装的本质就是让调用者不用了解类实现的细节,降低了学习和使用的成本,多态的本质则是在封装的基础上在进一步,多态则不需要知道当前是什么类,当代码很多的时候就会感受到封装和多态的作用

向上转型:

Fruits fruits=new Grape();

这句话表明任何葡萄都是水果,因此这种父类引用指向子类对象的写法叫做向上转型

向下转型:

Fruits fruit=new Grape();
Grape grape=fruits;

这第二句代码就表示所有的水果都是葡萄,这是不一定正确的,如果想让这句话正确,就需要进行强制类型转换,把fruits强制类型转换成Grape类,因此写成

Grape grape =Grape)fruits;

这样就可以表示出所有的水果都是葡萄

为了保险起见,可以加上一个检测机制来判断是否可以进行强制类型转换

Fruits fruits=new Apple();
if(fruits instanceof grape){
  Grape grape=(Grape)fruits;
}else{
  System.out.println("当前水果不是苹果,不能进行强转")
}

总结:向上转型应用场景:如果此时grape这个类新增了几个不属于fruits的父类的功能,那么此时如果不使用向下转型,直接调用

fruits.新功能名称,这时会报错因此就可以使用向上转型的方式来调用子类特有的功能

**向上转型:**此时通过父类引用对象是无法调用子类特有的方法的,此时通过父类引用变量调用的方法是子类覆盖或继承父类的方法,不是父类的方法

3.继承Thread,重写run,使用匿名内部类:

package Mode1;

public class Mode12 {
    public static void main(String[] args)  {
        Thread t = new Thread(){
            public void run(){
                while(true){
                    System.out.println("hello thread");
                    try {
                        Thread.sleep(1000);
                    } catch (InterruptedException e) {
                        throw new RuntimeException(e);
                    }
                }
            }
        };
        t.start();
        while(true){
            System.out.println("hello main");
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
        }
    }
}

先创建一个新类,这个类的类名是啥不用知道,只知道这个类是Thread的子类,同时把这个子类实例给创建出来了

4.实现Runnable接口,重写run,使用匿名内部类:

package Mode1;

public class Mode12 {
    public static void main(String[] args) throws InterruptedException {
        Runnable runnable=new Runnable(){
            public void run(){
                while(true){
                    System.out.println("hello Thread");
                }
            }
        };
        Thread t=new Thread(runnable);
        t.start();

        while(true){
            System.out.println("hello Main");
            Thread.sleep(1000);
        }
    }
}

5.基于lambda表达式:

最推荐的写法

lambda表达式,本质上是一个匿名函数,主要实现回调函数的效果

package Mode1;


public class Mode12 {
    public static void main(String[] args) {
        Thread t=new Thread(()->{
            while(true){
                System.out.println("hello thread");
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
            }
        });
        t.start();
        while(true){
            System.out.println("hello Main");
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
        }
    }
}

四。Thread类的其他使用方式

1.给创建的线程进行命名

package Mode1;


public class Mode12 {
    public static void main(String[] args) {
        Thread t=new Thread(()->{
            while(true){
                System.out.println("hello thread");
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
            }
        },"这是新线程");//这里对进程进行命名
        t.start();
    }
}

这时打开jconsole查看线程状态的时候,可以发现Main线程不见了,这是因为main执行完了,线程的入口方法执行完,就代表了一个线程结束,对于主线程来说,入口方法就是main方法,当t.start()执行完了main方法就执行完了,因此在jconsole就无法查看到main这个线程了

2.Thread的常见的几个属性

(1)getId()

就是表示线程的身份标识,表示进程中唯一的一个线程

(2)getName()

获取进程的名称

(3)getState()

获取进程的状态

(4)getPriority

获取线程的优先级

(5)isDaemon()

是否后台线程

后台线程:如果不结束,不会影响进程的结束

前台线程:如果不结束,会影响进程的结束

一般会默认一个线程为前台线程

(6)isAlive()

判断一个线程是否存活

3.线程启动

start:方法内部,会调用系统的api,在系统内核中创建线程,再执行run方法

run:方法,只是单纯的描述这个线程要做什么

看似两者很相似,实际上是有本质区别的,区别在于是否在系统内部创建一个新的线程

4.终止一个线程

终止(销毁)一个线程在java中其实很简单,就是将run方法内的代码进行尽快执行结束即可

方案一:手动创建标志位,让其作为执行结束的条件

package Mode1;


public class Mode12 {
    private static boolean isQuit=false;

    public static void main(String[] args) {
        Thread t=new Thread(()->{
            while(!isQuit){
                System.out.println("hello thread");
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
            }
        },"这是新线程");
        t.start();
        try {
            Thread.sleep(5000);
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }
        isQuit=true;
        System.out.println("设置isQuit为true");
    }
}

方案二:使用中断方法进行中断run方法的进行

package Mode1;


public class Mode12 {
    public static void main(String[] args) throws InterruptedException {
        Thread t=new Thread(()->{
            while(!Thread.currentThread().isInterrupted()){
                System.out.println("线程正在工作中");
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
            }
        },"这是新线程");
        t.start();
        Thread.sleep(5000);
        System.out.println("线程终止");
        t.interrupt();//这一步将Thread内部的标志位设为true
    }
}

5.线程等待

让一个线程等待另一个线程执行结束,再继续执行。本质上是控制线程结束的顺序

用join实现线程等待的效果

package Mode1;

public class Mode12 {
    public static void main(String[] args) throws InterruptedException {
        Thread t=new Thread(()->{
            for(int i=0;i<5;i++){
                System.out.println("t线程工作中");
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
            }
        });
        t.start();
        System.out.println("join等待开始");
        t.join();
        System.out.println("join等待结束");
    }
}

t.join工作过程:

如果t线程程正在运行中,此时调用join线程就会阻塞,一直阻塞到t线程执行结束为止

如果t线程已经执行结束,此时调用join线程,就会直接返回,不设计阻塞

t.join()

括号里面可以填入超时时间,也就是等待的最长时间(以毫秒为单位)

实际开发中不建议死等,建议有时间限制

五。线程的状态

NEW: Thread方法已经有了,start方法还没调用

TERMINATED:Thread对象还在,内核中的线程已经没了

RUNNABLE:就绪状态(线程已经在CPU上跑了/线程正在排队等待CPU执行

TIMED_WAIITING:阻塞,由于sleep这种固定时间构成的阻塞

WAITING:阻塞,由于wait这种不固定时间的方式产生的阻塞

BLOCKED:阻塞,由于锁竞争产生的阻塞

六。线程安全

有些代码在单个线程下运行是没有任何问题的,但是如果放到多个线程中进行运行就会出现问题,这个就叫作线程安全问题

package Mode1;
public class Mode12 {
    private static int count=0;
    public static void main(String[] args) throws InterruptedException {
        Thread t=new Thread(()->{
            for(int i=0;i<5000;i++){
                count++;
            }
        });
        Thread t2=new Thread(()-> {
            for(int i=0;i<5000;i++){
                count++;
            }
        });

        t.start();
        t2.start();

        //如果没有这俩join肯定不行,线程还没有自增完就开始打印了,所以这俩个join是必须要有的
        t.join();
        t2.join();
        System.out.println("count: "+count);
    }
}

上述代码是在实现,让一个线程自增5000次,另一个线程也自增5000次,希望是一共打印10000次

但是通过多次运行此代码,可以发现每次打印的结果都是不同的,那么这就说明出现bug了

如果将最下面的几行代码调一下顺序,就可以解决这个问题

t.start();
t.join();

t2.start();
t2.join();

这串代码的意思是t在运行的时候,t2是不会启动的

虽然上述代码是在两个线程运行的,但是不是同时运行

count++本质上是分成三步进行执行的:

Step1:load:把数据从内存中读到CPU寄存器当中

Step2:add:把寄存器的数据进行+1

Step3:Save:把寄存器的数据再转到内存当中

如果多个线程执行上述自增代码,由于线程的调度是随机的,那么就会导致这三步的顺序发生问题

在这里插入图片描述

所以我们要多线程中最困难的一点就是无法保证顺序是正确的,因此就会出现各种各样不同的答案,进而发现多次打印的结果都不相同,因此为了从根本上解决这个问题,就引入了上锁这个概念。

产生线程安全问题的原因:

(1)操作系统的调度是随机的(万恶之源)

(2)两个线程针对同一个变量进行修改

(3)修改操作不是原子的(向count++这种先读再修改的都是非原子的)类似的,向一定条件来决定是否修改,也是存在类似的问题

(4)内存可见性问题

(5)指令重排序问题

原因1和2是无法修改的,因此如果想解决这个问题,可以从原因3进行下手,让count++成为原子的,因此需要写枷锁

常用的枷锁就是sychronized方法进行枷锁

七。synchroniced关键字

Synchroniced在使用的时候,要搭配上代码块,在代码块中写入希望加锁的代码

Synchroniced(){
  
}

()中要写一个对象,这个对象是什么不重要,重要的是通过这个对象来判断两个线程是否在同时竞争一个锁

当两个线程针对同一个对象加锁时,这是就会出现锁竞争的现象

如果这两个线程针对不同的对象进行加锁,这样两个线程就是并行的了

就相当于两个男生在同时追一个女生,这时一个男生已经是这个女生的男朋友了,就相当于上了一个锁,另一个男生想要追求这个女生,就要等第一个男生分手以后(也就是解锁)才能开始追求

package Mode1;

import java.util.concurrent.SynchronousQueue;
public class Mode12 {
    public static void main(String[] args) throws InterruptedException {
        Object locker=new Object();//创建一个对象

        Thread t=new Thread(()->{
            synchronized(locker){
                for(int i=0;i<5000;i++){
                    count++;
                }
            }
        });
        Thread t2=new Thread(()-> {
            synchronized(locker){
                for(int i=0;i<5000;i++){
                    count++;
                }
            }
        });

        t.start();
        t2.start();

        t.join();
        t2.join();
        System.out.println("count: "+count);
    }
}

原理:

在这里插入图片描述

synchronized不仅可以用在代码块中,还可以用在静态方法或一个实例方法之中

例:

package Mode1;
import java.util.concurrent.SynchronousQueue;

class Counter{
    public int count=0;
  
    synchronized public void increase(){//synchronized修饰方法
        count++;
    }
}

public class Mode12 {
    public static void main(String[] args) throws InterruptedException {
        Counter counter =new Counter();
        Thread t=new Thread(()->{
               for(int i=0;i<5000;i++){
                    counter.increase();
                }
        });
        Thread t2=new Thread(()-> {
                for(int i=0;i<5000;i++){
                    counter.increase();
                }
        });

        t.start();
        t2.start();

        t.join();
        t2.join();
        System.out.println("count: "+counter.count);
    }
}

synchronized还有一个重要的特征:可重入的

所谓可重入锁,就是连续针对一把锁,加锁两次

死锁1:

synchronized(locker){
  synchronized(locker){
    
  }//(2)
}//(1)

当第一次加锁成功时,这时locker已经被锁定,如果进行第二次加锁的时候需要先将第一次加锁的locker进行释放,但是释放就需要先执行完大括号(1),但是在第二次加锁是在第(1)个大括号之内的,所以如果想进行第二次加锁,就要先把第一次的大括号执行完毕,因此构成了一个死循环,这就叫作“死锁”

因此,当synchornized是可重入的,就可以避免这个问题

死锁2:

package Mode1;
import java.util.concurrent.SynchronousQueue;

public class Mode12 {
    public static int count=0;

    public static void main(String[] args) throws InterruptedException {
        Object locker1=new Object();
        Object locker2=new Object();

        Thread t1=new Thread(()->{
            synchronized (locker1){
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
                synchronized (locker2){
                    System.out.println("t1加锁成功");
                }
            }
        });
        Thread t2=new Thread(()-> {
            synchronized (locker2){
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
                synchronized (locker1){
                    System.out.println("t2加锁成功");
                }
            }
        });

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

        t1.join();
        t2.join();
        System.out.println("count: "+count);
    }
}

上述代码的意思是:t1线程先拿到locker1,t2线程先拿到locker2,保证两个线程都个有一把锁,两个线程一秒以后,进行竞争获取自己没有的那把锁

运行此代码可以发现,不会打印任何东西,这就是死锁的另一种形式,两个锁是嵌套关系,不是并列关系

那么下面来总结一下死锁的成因:

(1)互斥使用(锁的基本特性),当一个线程已经获得一把锁了,另一个线程也想获得锁,就要阻塞等待

(2)不可抢占(锁的基本特性),当锁已被线程1获得之后,线程2只能等线程1主动释放,不开直接强占

(3)请求保持:一个线程尝试获得多把锁(先拿到锁1,再尝试得到锁2,但是不会释放锁1)

(4)循环等待:等待依赖关系形成环

解决死锁问题的关键就是,破坏上面任意一个成因就会避免死锁的出现

package Mode1;
import java.util.concurrent.SynchronousQueue;

public class Mode12 {

    public static void main(String[] args) throws InterruptedException {
        Object locker1=new Object();
        Object locker2=new Object();

        Thread t1=new Thread(()->{
            synchronized (locker1) {
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
            }
            synchronized (locker2){
                System.out.println("t1加锁成功");
            }
        });
        Thread t2=new Thread(()-> {
            synchronized (locker2) {
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
            }
            synchronized (locker1){
                System.out.println("t2加锁成功");
            }
        });

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

        t1.join();
        t2.join();
    }
}

上述代码是对成因3进行了破坏,让一个线程一次只获得一把锁,想获得第二把锁之前,先要把第一把锁进行释放

本质上,这个方法是把原来的嵌套关系,转换成现在的并列关系

下面将针对成因4进行破坏,可约定加锁顺序,就可以避免循环等待,针对锁进行编号,比如约定,加多把锁的时候,先加编号小的锁,再加编号大的锁:

package Mode1;
import java.util.concurrent.SynchronousQueue;

public class Mode12 {

    public static void main(String[] args) throws InterruptedException {
        Object locker1=new Object();
        Object locker2=new Object();

        Thread t1=new Thread(()->{
            synchronized (locker1) {
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
                synchronized (locker2) {
                    System.out.println("t1加锁成功");
                }
            }
        });
        Thread t2=new Thread(()-> {
            synchronized (locker1) {
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
                synchronized (locker2) {
                    System.out.println("t2加锁成功");
                }
            }
        });

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

        t1.join();
        t2.join();
    }
}

八。volatile关键字

volatile关键字的作用:(1)保证内容可见性。 (2)禁止指令重排序

计算机运行的代码经常要访问数据,这些依赖的数据,往往会存储在内存之中,CPU使用这个变量时,就会把这个内存先读出来,再存储到编辑器之中,再参与运算

CPU读取内存这个操作实际上是非常慢的(相对的),CPU上进行的大部分操作都是很快的,但是一旦涉及到读?写内存就会变得很慢

***结论:***为了解决上述问题,提高效率,编译器就会对代码进行优化,把一些本来要读内存的操作,优化成读取寄存器,减少读内存的次数,也就提高了整体的效率

package Mode1;
import java.util.Scanner;
import java.util.concurrent.SynchronousQueue;

public class Mode12 {
    private static int isQuit=0;
    public static void main(String[] args) throws InterruptedException {
        Thread t1=new Thread(()->{
            while(isQuit==0){
                //循环体里啥都不写,意味这个循环会以极高的速度进行循环
            }
            System.out.println("t1退出");
        });
        t1.start();
        Thread t2=new Thread(()-> {
            System.out.println("请输入isQuit");
            Scanner sc=new Scanner(System.in);
            isQuit=sc.nextInt();
        });
        t2.start();
    }
}

当运行此代码的时候,会发现,无论输入什么值,代码都不会打印任何结果

在while循环里的那个判断语句,实际上运行的顺序是(1)load读取内存中的isQuit到寄存器中(2)通过cmp指令比较值是否为0

由于这个循环的速度非常的快,因此编译器就会发现,虽然进行了多次load,但是load出来的结果都是一样的,并且load非常耗时,此时编译器就进行了优化操作,只是第一次循环的时候,才读取内存,后续就不读取内存了,而是从寄存器中,去除isQuit的值,因此会发现,无论我们怎么输入值,最后的运行结果都不会改变

这其实是编译器的一个Bug,所以volatile就是解决方法

在多线程环境下,编译器是否进行优化这一操作,判断不一定准,因此程序员可以通过volatile关键字告诉编译器,此处不需要进行优化

因此更改成以下这种形式,在isQuit前面,加上一个volatile关键字即可

private volatile static int isQuit=0;

另外volatile和synchronized都可以对线程安全起作用,但是volatile不能保证原子性

九。wait和notify

这两个关键字的作用是用来协调线程的执行顺序的

本身多个线程的顺序是随机的,因此希望使用一定的手段进行更改这个问题,使其变成有一定顺序的

join是影响线程结束的先后顺序的,相比之下,此处是希望线程不结束,也能有先后顺序的控制

package Mode1;
import java.util.concurrent.SynchronousQueue;
public class Mode12 {
    public static void main(String[] args) throws InterruptedException {
        Thread t1=new Thread(()->{
            for(int i=0;i<5;i++){
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
            }
            System.out.println("t1结束");
        });
        Thread t2=new Thread(()-> {
            try {
                t1.join();
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
            System.out.println("t2结束");
        });
        t1.start();
        t2.start();
        System.out.println("主线程结束");
    }
}

等待的过程和主线程没有什么关系,哪个线程调用join,哪个线程就进入阻塞状态

wait:等待,让指定线程进入阻塞状态

notify:通知,唤醒对应进入阻塞状态的线程

十。多线程代码案例

1.单例模式:

开发过程中,会遇到很多经典场面,针对这些经典场面,大佬们就提供了一些解决方案,按照解决方案进行编码结果就不会差

单例模式其实就单个实例对象,有写场景下,希望有多类只有一个对象,不能有多个对象

单例模式就是要保证代码中只能有一个实例,当有多个实例就会报错

package Mode1;

class Singleton{
    private static Singleton instance=new Singleton()//通过这个方法来获取刚才的实例,后续如果想要使用这个类的实例,都通过getInstance来获取
    public static Singleton getInstance(){
        return instance;
    }
    private Singleton(){}//把构造方法设为私有,此时类外面的其他代码,就无法new出这个对象了
}

public class Mode12 {
    public static void main(String[] args) {
        Singleton s1=Singleton.getInstance();//创建的方法
    }
}

这里在new对象的时机,是在类加载的时候(比较早的时机),因此叫做饿汉模式

总结:(1)在类的内部提供一个现成的实例。(2)把构造方法设为private,避免其他代码可以创建出实例

懒汉模式:

package Mode1;
class SingletonLazy{
    private static SingletonLazy instance=null;
    public static SingletonLazy getInstance(){
        if(instance==null){
            instance=new SingletonLazy();
        }
        return instance;
    }
    private SingletonLazy(){}
}

public class Mode12 {
    public static void main(String[] args) {
        SingletonLazy s1=SingletonLazy.getInstance();
    }
}

上述代码就是懒汉模式,只有首次调用getInstance的时候,才会真正创建出实例(如果不调用,就不创建)

懒在计算机中代表着高效率,可以省略一些不必要的操作,只有在必要的时候才会进行操作

举一个现实中的问题,在家吃完饭,如果是饿汉模式,会吃完饭立刻进行刷碗,如果是懒汉模式,会等下一次吃饭的时候再进行刷碗

那么,这时候就会出现一个问题:这两种写法是否存在线程安全呢?

先说结论:饿汉模式不涉及线程安全问题,懒汉模式会涉及到线程安全问题

如果多个线程同时修改一个变量,那么会涉及到线程安全问题

如果多个线程同时读取一个变量,那么就不会涉及到线程安全问题

public static Singleton getInstance(){
  return instance;
}

这个饿汉模式只涉及到读取,没有修改

if(instance==null){
  instance=new SingletonLazy();
}
return instance;

由于线程进行修改时,线程的执行顺序是随机的,因此可能会有下面这种可能性:

在这里插入图片描述

由于t1还没有来得及修改,此处的判定也是可以成立的,t2紧接着就会进行new操作(修改了instance)

这是当t2执行完之后,再回到t1的时候,由于t1这边,刚才条件成立,继续执行,就直接执行到了new操作

但是此时一共创建了两个线程,这就不是单例模式了,这就引发了线程安全问题

要想解决这个问题,其实很简单,就是进行加锁操作即可

那么锁加在哪里呢?

方案1:

public static SingletonLazy getInstance(){
        synchronized(SingletonLazy.class){
            if(instance==null){
                instance=new SingletonLazy();
            }
            return instance;
        }
}

一旦这么写就会发现一个严重的问题,在后续每次调用getInstance,都需要先加锁了,但是实际上懒汉模式,线程安全问题出现的地方都是最开始的时候(对象还没new呢),一旦对象new出来了,后续多线程调用getInstance的时候就只涉及到读操作了,就不会不安全了,所以会发现这个代码的效率太低了

方案2:

class SingletonLazy{
    private static SingletonLazy instance=null;
    public static SingletonLazy getInstance(){
        if(instance==null){
            synchronized(SingletonLazy.class){
                if(instance==null){
                    instance=new SingletonLazy();
                }
            }
        }
        return instance;
    }
    private SingletonLazy(){}
}

这里在锁外面又加了一个判断,这时候会发现出现了两次判断,且判断内容相同,但实际上,这两个判断的意义是不一样的

第一个判断是在判断是否要进行加锁

第二个判断是在判断是否要进行new对象

这样这个代码就可以解决方案一的问题了

但是又有了一个新的问题:指令重排序(CPU的优化)可能会对上述代码产生影响

new操作可以拆分成3步:

(1)申请内存空间

(2)在内存空间上构造对象(构造方法)

(3)把内存的地址,赋值给instance引用

在单线程中,可以按照123的顺序来执行,也可以按照132的顺序来执行(1一定是要先执行的)

但是在多线程之中就会有问题了

假设是按照132的顺序进行执行的,在t1线程执行完1和3的时候,此时instance就已经非空了,但是此时的instance还指向一个还没初始化的非法对象,这时t2线程开始执行了,t2先判定instance==null条件不成立,于是t2直接return instance了,进一步t2就有可能访问instance里的属性和方法了

因此要对instance加上volatile关键字进行处理

这样完成版的代码就如下所示了:

class SingletonLazy{
    private static volatile SingletonLazy instance=null;
    public static SingletonLazy getInstance(){
        if(instance==null){
            synchronized(SingletonLazy.class){
                if(instance==null){
                    instance=new SingletonLazy();
                }
            }
        }
        return instance;
    }
    private SingletonLazy(){}
}

2.阻塞队列:

这是多线程代码中常见的一种数据结构,是一种特殊的队列:

(1)线程安全

(2)带有阻塞特征;当队列为空的时候,如果继续出队列就会发生阻塞,阻塞到其他线程往队列中加元素为止;当队列为满时,如果继续入队列就会发生阻塞,阻塞到其他线程从队列中取走元素为止

阻塞队列最大的意义就是用来实现”消费者生产者模型“

比如说春节的时候,一家人在一起包饺子,两个人擀面皮,一个人包饺子;两个擀面皮的人就相当于生产者,不停的把擀好的面皮放到桌子上;包饺子的人就时消费者,从桌子上获取面皮

换成书面一点的语言就是:生产者会把东西放到阻塞队列之中,消费者就会从阻塞对列中获取内容

阻塞队列的优点:

1.解耦合:

两个模块越密切,耦合就越高,尤其是对于分布式系统来说更有意义
在这里插入图片描述

就像上面这个图片一样,服务器A与服务器B之间的耦合太高了,如果此时再加上服务器C,那么耦合程度将进一步提高,因此引入阻塞队列来解决这个问题
在这里插入图片描述

当把这里的阻塞队列封装成单独的服务器程序,部署到特定的机器上,这个时候就把他叫做消息队列

这时就会大大降低了耦合的程度,当服务器A出现问题时,不会影响到服务器B的工作;当增加服务器C时,此时A无需修改,只需要C从阻塞队列中获取信息即可

2.消峰填谷

峰指的是短时间内请求量多,谷指的是请求量少

还以上面的第一个图为例,这个图一旦服务器A的访问量多了,都会立即发给B,但是由于每个服务器上跑的业务不同,因此消耗的硬件资源也不相同,可能A承担这些没有问题,但是服务器B就不一定了

因此在此处加入一个生产者消费者模型,上述问题就可以解决了

就以上面的第二个图所示,假设B服务器每秒钟可以处理1000次请求,但是此时A服务器要求每秒处理3000次数据,这时引入阻塞队列可以帮助B承担压力,B服务器仍然可以按照1000次每秒的节奏处理请求;这个操作就叫做“消峰”

向这种情况不会出现的时间太长,只会段时间出现,过来峰值之后,A的请求量就恢复正常了,B就可以逐渐把积压的数据都处理掉了;这个操作就叫做“填谷”

在java标准库中,已经提供了现成的阻塞队列,让咱们直接使用

package Mode1;
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.LinkedBlockingQueue;

public class Mode12 {
    public static void main(String[] args) throws InterruptedException {
        BlockingQueue<String> queue=new LinkedBlockingQueue<>();//这一行就是在创建阻塞队列
        queue.put("111");//把数据放到阻塞队列中
        queue.put("2222");
        queue.put("3333");
        String elem =queue.take();//出阻塞队列的第一个元素
        System.out.println(elem);
        elem =queue.take();
        System.out.println(elem);
    }
}

下面将不使用标准库中的阻塞队列,自己实现阻塞队列

在这里插入图片描述

[head,tail)就组成了一个区间,这个区间里的内容就是队列中的有效元素

入对列的时候就是要将元素放入head中,然后tail++

出队列的时候就是将head指向的元素删除,同时head++

当head和tail重合的时候就表示数组为空或者是满的,那么如何进行区分呢?

方案1:浪费一个格子,让tail指向head的前一个的时候就为满

方案2:专门搞一个变量size,用来记录数组中有几个数据,当size为数组的长度最大值的时候则为满,如果size为0的时候则为空

class MyBlockQueue{
    private String[] data=new String[100];
    private int head=0;
    private int tail=0;
    private int size=0;
    
    private Object locker=new Object();
    public void put(String elem) throws InterruptedException {
        synchronized(locker){
            synchronized (locker){
                if(size==data.length){
                    locker.wait();
                }
                data[tail]=elem;
                tail++;
                if(tail==data.length){
                    tail=0;
                }
                size++;
                locker.notify();
            }
        }
    }
    
    public String take() throws InterruptedException {
        synchronized (locker){
            if(size==0){
                locker.wait();
            }
            String ret=data[head];
            head++;
            if(head==data.length){
                head=0;
            }
            size--;
            locker.notify();
            return ret;
        }
    }
}

这里加入了锁是为了解决线程安全问题

代码中有wait和notify的意义是实现阻塞数组的作用,当数组满的时候,put进行阻塞等待,直到take一个元素的时候,进行唤醒。同样,当数组为空的时候则不能take,take进行了阻塞,直到put的时候进行唤醒

public class Mode12 {
    public static void main(String[] args) throws InterruptedException {
        MyBlockQueue queue=new MyBlockQueue();
        Thread t1=new Thread(()->{
            while(true){
                try {
                    String result=queue.take();
                    System.out.println("消费元素"+result);
                    Thread.sleep(500);
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
            }
        });

        Thread t2=new Thread(()->{
            int num=1;
            while(true){
                try {
                    queue.put(num+"");
                    System.out.println("生产元素"+num);
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
            }
        });
        t1.start();
        t2.start();
    }
}

这里实现里多线程,实现了生产者消费者模型。

3.时间表:

概念:预定一个时间,时间到达之后执行某个代码逻辑

定时器非常常见,尤其是进行网络通信的时候

就比如说客户端发出请求给服务器,如果客户端发出请求之后,要等待响应,如果此时服务器一直没有给出回应,那么这时客户端就进入了死等状态,这种现象是不好的,因此为了解决这个问题,就需要有一个最大的等待期限,如果期限一到,可选择重发或者彻底放弃,还是其他的方式

此处的最大等待时间就是时间表

在标准库中是有时间表的实现的

import java.util.Timer;
import java.util.TimerTask;

public class Demo1 {
    public static void main(String[] args) {
        Timer timer=new Timer();
        timer.schedule(new TimerTask() {
            @Override
            public void run() {
                System.out.println("执行定时器任务");
            }
        },2000);
        System.out.println("程序启动");
    }
}

这串代码的运行结果就是,先“线程启动”,等2秒之后进行run方法内部的实现执行,打印“执行定时器任务”

4.线程池:

线程创建的意义就是因为进程创建/销毁太重量,如果创建或销毁的频率太高,则线程也是顶不住的

解决方法:

1.协程:又称轻量级线程,主要就是把系统调度的过程省略了。这个方法虽然好,但是在java中不流行使用,这种方法经常在Go和Python中使用

2.线程池:使用方式就是,在使用第一个线程的时候,同时把二,三,四…线程创建好,后续如果想使用新的线程,可以直接使用

那么此时就会有这么一个疑问:为什么池子取的效率比新创建线程的效率高?

从池子中取是一个纯用户态的操作,但是创建一个线程是用户态+内核相互配合完成的操作

创建线程操作,就要调用系统api,进入到内核之中,按照内核态的方式进行一系列的操作

举一个例子:假设一个人去银行取钱,这个人现在需要打印一个账单,他可以有两个选择,直接去银行里的打印机旁打印,另一种就是去前台找人去完成打印,这时就会涉及到一个问题,工作人员会帮这个人去打印账单,但是中间可能会先做其他事情,因此这个效率就会和直接去打印相差很大。

这里的直接去打印机复印就相当于从池子中取,而找工作人员去打印就相当于创建线程

java中也提供了线程池,可以直接使用

ExecutorService service= Executors.newCachedThreadPool();

Executors:表示工厂类

newCachedThreadPool:表示工厂方法

随着向线程池添加新的任务,这时,线程池会自动的创建出线程,创建出来之后,不用着急销毁,会在线程池中保留一段时间,什么时候用,什么时候从线程池中取

在newCachedThreadPool( )的括号中,可以填写数字,作用就是可以表明一次性创建出几个线程

上述这个工厂方法创建出来的线程池,本质上就是对一个类进行的封装。(就是ThreadPoolExecutor这个类)

ThreadPoolExecutor核心方法(1)构造 (2)注册任务

先写一下注册任务的写法

import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

public class Demo1 {
    public static void main(String[] args) {
        ExecutorService service= Executors.newCachedThreadPool();
        service.submit(new Runnable(){//添加任务的写法
            @Override
            public void run() {
                System.out.println("hello world");
            }
        });
    }
}

构造:

ThreadPoolExecutorint corepoolSize,int maximumPoolSize,long keepAliveTime,TimeUnit unit,BlockingQueue<Runnable> workQueue,ThreadFactory threadFactory,RejectedExecutionHandler handler)

下面进行分别讲解这串构造中的代码意思

(1)int corePoolSize 指的是核心线程数

int maximumPoolSize 指的是最大线程数

理解这两个概念其实很简单,举一个例子:假设在公司中,有两类员工(正式员工,实习生),正式员工就相当于核心线程数,正式员工+实习生就是最大线程数

书面一点的表示就是线程的数量是[corePoolSize,maximumPoolSize] 这个区间范围的,数量是可以动态变化的

(2)long keepAliveTime 指的是除核心线程的其他线程可休息的时间数值

​ TimeUnit unit指的是时间数值的单位

(3)BlockingQueue workQueue 指的是阻塞队列,用来存放线程池任务的

这个阻塞队列是可以进行自定义的,如果需要优先级,可以设置为PriorityBlockingQueue,如果不需要优先级且任务数目相对恒定,则可设为ArrayBlockingQueue,如果不需要优先级且任务数目不稳定,则可设为LinkedBlockingQueue

(4)ThreadFactory threadFactory 是工厂模式的体现,是用来创建线程的

(5)RejectedExecutionHandler handler 指的是线程池的拒绝策略,当一个线程池能容纳的任务达到上限的时候,如果继续添加会有什么样的效果
一个常见的面试题:使用线程池,创建多少个线程合适?

在这里插入图片描述

回答:使用实验的方法,对程序进行性能测试(这里的实验方法指的是尝试修改不同线程池的线程数目,测试性能,直到找到一个性能最好的线程个数)


网站公告

今日签到

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