Java多线程和锁

1. 高并发和多线程

高并发指的是在同一时刻/短时间内接收到大量的请求,例如双十一,在12点整的时候,会有大量的用户同时进行商品下单操作,如果系统没有应对高并发的措施,那么会出现同一时刻若干请求同时落到服务器,造成服务器奔溃。

多线程是解决高并发的一种措施,但并不是一定得通过多线程去解决高并发,还可以通过消息队列,进行“削峰”,先缓冲大量的请求;或者可以通过redis缓存,减轻数据库的压力;同时,仅靠多线程编程也是无法解决高并发的,因为CPU的处理器是有限的,无法在同一时刻同时处理大量的请求。

进程是程序的一次执行,程序可以理解成我们编写的代码,当系统运行代码的时候,就相当于执行了程序,启动了一个进程;对于我们编写的Java代码,例如某个方法,可以理解成这个进程的一些公用资源,当多个线程同时访问我们程序的某个方法时,相当于访问了进程的同一块共享资源,如果我们的方法涉及了数据的增删改操作,就需要考虑线程安全的问题,是否需要对该方法加锁,保证线程安全。

多线程编程指的是在我们的代码片段中,会开一个或多个的线程去执行其它操作,最常用的是新开线程来执行一些耗时的操作,例如上传文件之类的;举一个例子:我们希望查询sql,并且将该sql保存到数据库中,如果这两步操作是在同一个线程处理的话,那么查询完sql会等待sql保存到数据库完成才会返回值,相当于是同步的操作;返回查询sql值会被保存到数据库操作给阻塞,磁盘IO又是比较耗时的操作,会降低性能,给用户不好的体验。如果我们在main线程中开一个新线程去执行保存到数据库的操作,这两步操作就是异步的,sql保存到数据库的操作不会阻塞返回查询sql值,查询完sql就可以立即返回值,让另一个线程去后台执行保存数据库的操作就行了。

2. 线程的五种状态

线程的五种状态(也可以说是线程的六种状态,这里把Waiting等待状态和Blocked阻塞态统称为阻塞态)
1、新建状态(New)
2、就绪状态(Runnable)
3、运行状态(Running)
4、阻塞状态(Blocked)
5、死亡状态(Dead)
请添加图片描述
线程共包括以下 5 种状态:

1、新建状态(New): 线程对象被创建后,就进入了新建状态。例如,Thread thread = new Thread()。

2、就绪状态(Runnable): 也被称为“可执行状态”。线程对象被创建后,其它线程调用了该对象的start()方法,从而来启动该线程。例如,thread.start()。处于就绪状态的线程,随时可能被CPU调度执行。

3、运行状态(Running): 线程获取CPU权限进行执行。需要注意的是,线程只能从就绪状态进入到运行状态。

4、阻塞状态(Blocked): 阻塞状态是线程因为某种原因放弃CPU使用权,暂时停止运行。直到线程进入就绪状态,才有机会转到运行状态。阻塞的情况分三种:

  • 等待阻塞 – 通过调用线程的wait()方法,让线程等待某工作的完成。
  • 同步阻塞 – 线程在获取synchronized同步锁失败(因为锁被其它线程所占用),它会进入同步阻塞状态。
  • 其他阻塞 – 通过调用线程的sleep()或join()或发出了I/O请求时,线程会进入到阻塞状态。当sleep()状态超时、join()等待线程终止或者超时、或者I/O处理完毕时,线程重新转入就绪状态。

5、死亡状态(Dead): 线程执行完了或者因异常退出了run()方法,该线程结束生命周期。

3. 线程相关方法
1. Object类的方法

wait:线程进入阻塞态,会释放锁,等待唤醒
notify:唤醒某一个正在等待唤醒的线程,具体唤醒哪一个线程不取决于我们的选择,取决于 JVM 的实现
notifyAll:唤醒所有正在等待唤醒的线程

注意:线程被唤醒之后,并不一定立即进入就绪态,还需要拿到对应的锁才能进入就绪态,否则还是处于等待锁的状态(阻塞态)

2. Thread类的方法

sleep:Thread类的静态方法,线程睡眠,线程会放弃已经获取到的CPU资源,进入阻塞态,但是线程并不释放已获取到的锁
join:具体线程对象调用的方法,等待线程执行完毕,调用了哪个线程对象的join方法就会等待该线程执行完毕才会继续往下执行,例如在main线程中执行了thread1.join(),此时main线程进入阻塞态,并且main线程会等待thread1线程全部执行完毕之后,再继续往下执行
yield:Thread类的静态方法,线程让步,线程放弃已经获取到的CPU资源,但不释放锁,进入就绪态,有可能线程调用完yield方法之后又立即获得CPU调度进入运行态
4. Runnable和Callable

Runnable接口有两大缺陷:一是run方法没有返回值,二是run方法不能抛出异常(如果方法内产生Checked Exception,只能通过try catch进行处理,不能直接throws抛出)

通过查看Runnable接口的源码可以知道,run方法的返回值类型是void,并且方法的声明没有抛出异常,而根据子类重写方法的其中一个原则——子类抛出的异常不能大于父类,可以知道,既然Runnable接口的run方法没有抛出异常,那么子类重写也不能抛出异常。
请添加图片描述
举例:编写一个Runnable接口的实现类,实现run方法,方法里面主动抛出了checked exception,如果我们在run方法里面不处理这个异常,直接throws出去,代码无法编译通过
请添加图片描述
我们只能在方法体里面对异常进行处理
请添加图片描述
注意:如果代码里面出现的是运行时异常,我们无需进行try catch处理,代码也可以正常编译通过
请添加图片描述
而Callable接口的出现正是为了解决Runnable的两大缺陷,Callable接口的call方法既可以有返回值,也可以抛出异常。
通过查看Callable接口的源码可以知道:
请添加图片描述

5. Future接口

Future接口有五个方法,通过这五个方法可以灵活的操作子线程。

一般来说,我们调用线程池的submit方法向线程池提交任务,就可以返回一个Future接口的实现类,调用线程池的submit方法,通常传入的是一个Callable接口的实现类,因为call方法有返回值,通过Future对象就可以获取call方法的返回值;当然,也可以传入一个Runnable接口的实现类,只不过调用Future对象的get方法获取子线程的执行结果时,如果子线程正常执行结束,返回一个null值。

请添加图片描述

1. get():获取子线程执行结果
    a. 任务正常完成:get方法会立即返回结果
    b. 任务尚未完成(未开始或进行中):get方法将阻塞并直到任务完成
    c. 任务执行过程抛出异常(call方法抛出异常):无论抛出什么异常,get方法抛出的异常类型都是ExecutionException
    d. 任务被取消:get方法会抛出CancellationException
    
2. get(long timeout, TimeUnit unit):有设置超时的获取结果
    当子线程在规定时间内完成任务,那么正常获取到返回值,如果超时,会抛出TimeoutException
    一般来说,当捕获到TimeoutException时,会伴随取消线程任务的操作,不然的话子线程还是会继续执行

3. cancel(boolean mayInterruptIfRunning):取消任务的执行
    a. cancel(false):只能取消未开始的任务,无法取消运行中的任务;如果取消了未开始的任务,结果返回true,如果任务已完成,相d当于没做取消操作,返回false
    b. cancel(true):既可以取消未开始的任务,也可以取消运行中的任务;如果取消了未开始的任务,结果返回true,如果任务已完成,相当于没做取消操作,返回false,如果取消了运行中的任务,返回true,并且被取消的任务会捕获到一个中断异常InterruptedException,通常子线程收到中断异常信号,应该做一些兜底操作
    注意:使用cancel(true)是比较危险的操作,因为会直接中断正在运行的子线程,通常,如果子线程会对接收到InterruptedException中断异常信号做一些处理,也就是说,被中断时会做一些必要的操作,那么我们可以放心的使用cancel(true)
    
4. isDone():判断线程是否执行完毕
    不管线程是正常执行完毕还是异常结束,只要不是运行中,都算执行完毕,结果返回true
    
5. isCancelled():判断任务是否被取消

举例如下:

以下代码运行结束会输出:执行超时

public class Main {
    public static void main(String[] args) {
        ExecutorService executor = Executors.newFixedThreadPool(2);
        Future<Integer> future = executor.submit(new CallableTest());
        try {
            // 设置一秒的超时值,如果子线程执行超时,get方法会抛出TimeoutException
            Integer res = future.get(1, TimeUnit.SECONDS);
        } catch (InterruptedException e) {
            e.printStackTrace();
        } catch (ExecutionException e) {
            // 如果子线程执行抛出异常,无论是什么类型的异常,get方法捕获到的都是ExecutionException
            e.printStackTrace();
        } catch (TimeoutException e) {
            System.out.println("执行超时");
            e.printStackTrace();
        }
        // 停止线程池,不然程序一直处于运行状态
        // 另外,停止线程池之后,该线程池将无法再次使用
        executor.shutdown();
    }
}
class CallableTest implements Callable {
    @Override
    public Integer call() throws Exception {
        System.out.println("子线程执行");
        try {
            Thread.sleep(3000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        return 1;
    }
}
6. FutureTask的作用

一般来说,我们会通过调用线程池的submit方法,返回一个Future对象,拿到线程对应的Future对象之后,就可以对线程进行一些操作,比如获取线程的执行结果、取消线程执行任务、判断线程是否执行完成等。

但如果我们不想通过线程池提交任务的方式,又希望可以获取到线程的Future对象,那么可以使用FutureTask。FutureTask是一个类,实现了RunnableFuture接口,而RunnableFuture接口又继承了Runnable接口和Future接口,也就是说,FutureTask既可以作为Runnable被线程执行,又可以作为Future得到Callable的返回值。举例如下:

public static void main(String[] args) {
    // 创建一个FutureTask对象,并传入一个Callable实现类
    FutureTask<Integer> futureTask = new FutureTask<>(new Callable<Integer>() {
        @Override
        public Integer call() throws Exception {
            return 1;
        }
    });
    // 创建一个线程类,并把FutureTask作为参数传递,其实FutureTask就充当了Runnable对象的角色
    Thread t = new Thread(futureTask);
    t.start();
    try {
        // 可以通过futureTask对象获取线程的执行结果
        System.out.println(futureTask.get());
    } catch (InterruptedException e) {
        e.printStackTrace();
    } catch (ExecutionException e) {
        e.printStackTrace();
    }
}

FutureTask的存在也运用到了设计模式中的适配器模式,创建一个线程类new Thread,只能传递一个Runnable接口的实现类作为参数,而无法传递一个Callable接口的实现类作为参数,而通过FutureTask作为中转,就可以实现Thread类也可以接收一个Callable接口的实现类作为参数,其中FutureTask就充当了适配器的角色。
请添加图片描述

7. CountDownLatch、CyclicBarrier、Semaphore

join 的主要作用是让一个线程去等待另一个线程,或者是一等多,或者是多等一。对于这个功能,JDK 已经封装好了非常好的工具类 CountDownLatch,CyclicBarrier,这两个工具类都可以达到类似的效果,推荐使用这样的成熟工具类实现相应逻辑,防止自己操作底层方法出现错误。

A. CountDownLatch:减法计数器
使用该工具类,可以使一个线程等待其他线程各自执行完毕后再执行。CountDownLatch是通过一个计数器来实现的,计数器的初始值是线程的数量。每当一个线程执行完毕后,计数器的值就减一,当计数器的值为0时,表示所有线程都执行完毕,然后等待的线程就可以恢复工作了。

生活中的例子:班级关门,假设班里现在有6个人在学习,那么老师必须等待所有学生离开之后才能执行关门操作。

CountDownLatch类的常用方法:

//调用await()方法的线程会被挂起,它会等待直到count值为0才继续执行
public void await() throws InterruptedException { };   
//和await()类似,只不过等待一定的时间后count值还没变为0的话就会继续执行
public boolean await(long timeout, TimeUnit unit) throws InterruptedException { };  
//将count值减1
public void countDown() { }; 

举例:

public static void main(String[] args) throws InterruptedException {
    // 减法计数器 (设置了初始值为6)
    CountDownLatch countDownLatch = new CountDownLatch(6);
    // 自定义一个线程池
    ThreadPoolExecutor threadPoolExecutor = new ThreadPoolExecutor(2, 5, 3, TimeUnit.SECONDS, new LinkedBlockingQueue<>(3), Executors.defaultThreadFactory(), new ThreadPoolExecutor.AbortPolicy());
    // 开启6个线程,每个线程执行完毕,计数器减1
    for (int i = 0; i < 6; i++) {
        threadPoolExecutor.execute(()->{
            System.out.println("go~");
            // 执行到此处,说明线程执行完毕,计数器减一
            countDownLatch.countDown();
        });
    }
    // 停止线程池
    threadPoolExecutor.shutdown();
    // main线程执行到这行代码时,会去检查计数器的值,当计数器减为0的时候才会往下执行,否则一直等待
    countDownLatch.await();
    System.out.println("所有线程已经执行完毕~~~");
}

代码运行结果:
在这里插入图片描述

B. CyclicBarrier:加法计数器

它的作用就是提供一个屏障,只有指定线程数量的所有线程都到达这个屏障,才会继续往下执行。

生活中的例子:去餐厅吃饭,假设有五个人去餐厅吃饭,有些人早到,有些人晚到,但是餐厅限制我们必须人到齐后才能进去。

参考文章:https://www.jianshu.com/p/333fd8faa56e

举例:

public static void main(String[] args) throws InterruptedException {
        // 集齐七个线程,才会执行这个线程
        CyclicBarrier cyclicBarrier = new CyclicBarrier(7, ()->{
            System.out.println("所有线程都到达了,可以冲破屏障了~~~");
        });

        for (int i = 1; i <= 7; i++) {
            // 注意:lambda表达式中无法直接访问到for循环的i变量
            // 可以用一个临时变量进行中转一下,一般会加final关键字修饰,不加也可(jdk1.8后才可以不加)
            final int temp = i;
            new Thread(()->{
                System.out.println("线程" + temp +  "到达屏障");
                try {
                    // 如果7个线程中还有线程没到达这个屏障,就会等待
                    cyclicBarrier.await();
                } catch (BrokenBarrierException e) {
                    e.printStackTrace();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                System.out.println("线程" + temp +  "冲破屏障");
            }).start();
        }
    }

代码运行结果:
在这里插入图片描述
对于final关键字的补充:

在Java8之前,匿名内部类、局部内部类要访问的局部变量必须被final修饰,否则无法访问;在Java8开始这个限制就被取消了,变得更加智能,也就是说如果局部变量被局部内部类或者匿名内部类访问了,那么该局部变量就相当于自动使用了final修饰,由于final的不可变性,后面也不能再对这个变量赋值了!

注意:Lambda表达式其实也是一个匿名内部类,遵守上面的规则

C. Semaphore:计数信号量

Semaphore 是 synchronized 的加强版,作用是控制线程的并发数量,也就是可以控制同时访问资源的线程个数,可以用它来实现“限流”。

生活中的例子:抢车位,假设只有三个车位,那么同一时刻只能有三辆车可以停,其它车必须等待有车开走之后释放停车位才有机会。

举例:

public static void main(String[] args) throws InterruptedException {
    // 初始化信号量为3,表示只允许3个线程同时访问
    Semaphore semaphore = new Semaphore(3);
    // 自定义一个线程池
    ThreadPoolExecutor threadPoolExecutor = new ThreadPoolExecutor(2, 5, 3, TimeUnit.SECONDS, new LinkedBlockingQueue<>(3), Executors.defaultThreadFactory(), new ThreadPoolExecutor.AbortPolicy());
    // 开启6个线程
    for (int i = 0; i < 6; i++) {
        threadPoolExecutor.execute(()->{
            System.out.println(Thread.currentThread().getName() + "拿到资源~");
            try {
                semaphore.acquire(); // 申请许可(信号量),如果此时信号量为0,那么会阻塞在这里等待,直到有线程归还信号量
                TimeUnit.SECONDS.sleep(2);
                System.out.println(Thread.currentThread().getName() + "释放资源~");
            } catch (InterruptedException e) {
                e.printStackTrace();
            } finally {
                semaphore.release(); // 归还信号量,其它处于阻塞等待信号量的线程才有机会继续执行
            }
        });
    }
}
8. synchronized 关键字
  • synchronized修饰普通方法,锁的是当前对象this,也就是哪个对象调用了这个方法,锁的就是这个对象;如果两个线程是通过同一个对象调用该对象的synchronized普通方法,可以保持同步,因为锁的是同一个对象,必须等待一个线程释放锁,另一个线程才能执行;如果两个线程是通过两个对象调用该对象的synchronize普通方法,无法保持同步,因为锁的不是同一个对象。
  • synchronized修饰静态方法,锁的是当前类的class对象,由于类的class对象只有一个,所以即便两个线程是通过两个对象调用该对象的synchronized静态方法,也可以保持同步,因为锁的是同一个对象。
  • synchronized修饰代码块,可以灵活的指定要锁的对象,可以是当前类的class对象,也可以是当前对象this,还可以是其它指定的任何对象,synchronized修饰代码块也跟synchronized修饰方法一样,分为类锁和对象锁,类锁是所有对象共用一个锁,对象锁是一个对象一把锁,多个对象多把锁。
9. synchronized锁和Lock锁的区别
  • Lock锁是java.util.concurrent.locks包下面的一个接口,它有三个实现类,分别是ReentrantLock(可重入锁)、ReentrantReadWriteLock.ReadLock(读锁)、ReentrantReadWriteLock.WriteLock(写锁)。
  • synchronized是内置的java关键字,用于修饰方法或者代码块;而Lock锁是java类(接口)。
  • synchronized加锁和释放锁都是自动的,而Lock锁加锁释放锁是手动的,步骤是:先创建一个Lock锁的实现类,调用lock()方法进行上锁,被锁起来的业务代码需要捕获异常,最后调用unlock()方法释放锁,如果不释放锁,会导致死锁。
  • synchronized无法获取锁的状态,而Lock锁可以判断是否获取到了锁。
  • 对于synchronized锁,如果线程1获得锁,然后处于阻塞态,那么线程2会一直傻傻的等待线程1释放锁;而Lock锁不会一直等待下去。
  • synchronized锁适合锁少量代码同步问题,而Lock锁适合锁大量代码同步问题。
  • synchronized是可重入锁,不可以中断,非公平;Lock锁也是可重入锁,可以判断锁,可以设置锁为公平或者不公平。
10. JMM(Java Memory Model)

JMM指的是java内存模型,不是具体存在的,只是一个概念,一种约定!JMM要与JVM区分开,JVM是java虚拟机,是存在的东西;JMM定义了主内存和工作内存两个概念,并且主内存和工作内存有八种交互操作,分别是lock、unlock、read、load、use、assign、store、write;另外,JMM对这八种指令的使用,也制定了八种操作规则;JMM的三种特征就是原子性、可见性、有序性。

JMM相关文章:https://www.cnblogs.com/null-qige/p/9481900.html
请添加图片描述

11. volatile关键字

volatile是java虚拟机提供的轻量级同步机制,只能用于修饰变量,它可以保证可见性、有序性(禁止指令重排),但是不能保证原子性;而synchronized关键字既可以保证可见性、有序性,也可以保证原子性。

使用volatile的好处:从底层实现原理我们可以发现,volatile是一种非锁机制,这种机制可以避免锁机制引起的线程上下文切换和调度问题。因此,volatile的执行成本比synchronized更低。

volatile关键字相关文章:https://www.cnblogs.com/null-qige/p/8569131.html

volatile实现可见性的原理(MESI协议):https://blog.csdn.net/nch_ren/article/details/78924808

A. 关于指令重排

指令重排指的就是程序的运行顺序并不一定是按照我们代码编写的先后顺序执行的,jvm对代码进行编译的时候会进行指令优化,调整互不关联的两行代码执行顺序,在单线程的时候,指令优化会保证优化后的结果不会出错。但是在多线程的时候,指令重排就可能出现问题。

注意的是:指令重排是需要遵循数据的依赖性的,例如 int x = 0; x += 10; 这两行代码进行指令重排,后面这行代码也是不会排在第一行代码之前执行的,因为变量必须先定义后再使用,这就是所谓的数据依赖性。

volatile和synchronized都可以避免进行指令重排,满足了JMM的有序性,那么是如何实现禁止指令重排的呢?

这涉及到一个叫做“内存屏障”的概念,《深入理解Java虚拟机》中有一句话:“观察加入volatile关键字和没有加入volatile关键字时所生成的汇编代码发现,加入volatile关键字时,会多出一个lock前缀指令”,lock前缀指令生成一个内存屏障。保证重排序后的指令不会越过内存屏障,即volatile之前的代码只会在volatile之前执行,volatile之后的代码只会在volatile之后执行。

B. volatile不支持原子性

public class Demo2 {
	// 共享变量
    private volatile static int num = 0;
    public static void add() {
        num++;
    }
    public static void main(String[] args) {
        for (int i = 0; i < 20; i++) {
            new Thread(()->{
                for (int j = 0; j < 100; j++) {
                    add();
                }
            }).start();
        }
        // main线程等待让步其它的线程,因为默认的线程数为2个,main线程和gc线程
        while (Thread.activeCount() > 2) {
            Thread.yield();
        }
        System.out.println(num);
    }
}

以上代码预期的结果是输出2000,但是每次运行的结果都小于2000,原因就是num++是一个非原子性操作,它包含三个动作,先读取内存中num的值,然后执行加一操作,再将结果赋值给num;由于不是原子操作,多线程下就会出现问题,即使给num变量加上volatile关键字修饰,也是不行,因为volatile只能保证可见性,不能保证原子性;可以在add方法添加synchronized关键字加锁实现同步。

对于volatile不能保证可见性的问题,除了使用synchronized加锁,还可以使用效率更高的java.util.concurrent.atomic包下的原子类,例如使用AtomicInteger类的getAndIncrement方法就可以保证原子性,他就类似于int型变量的++操作;底层是使用了CAS实现原子性的。

将上面的代码做如下修改,就可以达到预期值了

private volatile static AtomicInteger num = new AtomicInteger();
public static void add() {
    num.getAndIncrement();
}
  • 3
    点赞
  • 6
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值