Java多线程初级

182 阅读27分钟

目录

  1. 线程和进程的概念与区别
  2. 线程的创建和启动
  3. 线程的生命周期
  4. Thread类的常用方法
  5. Object类关于线程的方法
  6. 线程的同步
  7. 用Lock实现线程间通信
  8. ThreadLocal

1. 线程和进程的概念与区别

进程指的是占用一定内存的程序当内存中的程序被清除,进程即结束。

线程是进程中的一个执行单元,负责当前进程中程序的执行。一个进程中至少有一个线程。

进程是资源分配的单位,线程是执行单位。早期操作系统没有线程,只有进程。但是进程非常“重”,进程间切换成本高。为了降低并发导致的进程切换成本,提出了线程。一个进程可以拥有多个线程。尽量让线程间进行切换,线程不拥有资源(或者说是很少的必要的资源)。

需要注意的是,Java本身并不能创造线程,因为线程其实是操作系统的一种资源,它由操作系统管理。我们一般说“Java支持多线程”,指的就是Java可以调用系统资源创建多线程。

2. 线程的创建和启动

2.1. 多线程实现的原理

  • Java语言的JVM允许程序运行多个线程,多线程可以通过Java中的java.lang.Thread类来体现。
  • Thread类的特性
    • 每个线程都是通过某个特定的Thread对象的run()方法来完成操作的,经常把run()方法的主体称为线程体。
    • 通过Thread方法的start()方法来启动这个线程,而非直接调用run()。

2.2. 多线程的创建,方式一:继承于Thread类

  1. 创建一个继承于Thread类的子类。
  2. 重写Thread类的run()方法。
  3. 创建Thread类的子类的对象。
  4. 通过此对象调用start()来启动一个线程。
public class ThreadDemo1 extends Thread {
    public static void main(String[] args) { 
        // ThreadDemo1继承了Thread类,并重写run() 
        ThreadDemo1 t = new ThreadDemo1();
        // 开启线程:t线程得到CPU执行权后会执行run()中的代码 
        t.start();
    }
    
    @Override 
    public void run() { 
        System.out.println("Thread is running"); 
    }
}

2.3. 多线程的创建,方式二:实现Runnable接口

  1. 创建一个实现Runnable接口的类。
  2. 实现类去实现Runnable接口中的抽象方法:run()
  3. 创建实现类的对象。
  4. 将此对象作为参数传到Thread类的有参构造器中,创建Thread类的对象。
  5. 通过Thread类的对象调用start()方法。
public class ThreadDemo2 implements Runnable{
    public static void main(String[] args) {
        // ThreadDemo2实现Runnable接口,并实现run()
        ThreadDemo2 target = new ThreadDemo2();
        // 调用Thread构造方法,传入TreadDemo2的实例对象,创建线程对象
        Thread t = new Thread(target);
        // 开启线程:t线程得到CPU执行权后会执行run()中的代码
        t.start();
    }

    public void run() {
        System.out.println("Thread is running");
    }
}

2.3.1 比较创建线程的两种方式

  • Java中只允许单进程,以ThreadDemo1类来说,很有可能这个类本来就有父类,这样一来就不可以继承Thread类来完成多线程了,但是一个类可以实现多个接口,因此实现接口的方式没有类的单继承性的局限性,用实现Runnable接口的方式来完成多线程更加实用。
  • 实现Runnable接口的方式天然具有共享数据的特性(不用static变量)。因为继承Thread的实现方式,需要创建多个子类的对象来进行多线程,如果子类中有变量A,而不使用static约束变量的话,每个子类的对象都会有自己独立的变量A,只有static约束A后,子类的对象才共享变量A。而实现Runnable接口的方式,只需要创建一个实现类的对象,要将这个对象传入Thread类并创建多个Thread类的对象来完成多线程,而这多个Thread类对象实际上就是调用一个实现类对象而已。实现的方式更适合来处理多个线程有共享数据的情况。
  • 联系:Thread类中也实现了Runnable接口
  • 相同点:两种方式都需要重写run()方法,线程的执行逻辑都在run()方法中

2.4. 多线程的创建,方式三:实现Callable接口

与Runnable相比,Callable功能更强大,JDK5新特性

  1. 相比run()方法,可以有返回值
  2. 方法可以抛出异常
  3. 支持泛型的返回值
  4. 需要借助FutureTask类,比如获取返回结果

/**
 * 创建线程的方式三:实现Callable接口。 ---JDK5新特性
 * 如何理解Callable比Runnable强大?
 * 1.call()可以有返回值
 * 2.call()可以抛出异常被外面的操作捕获
 */

//1.创建一个实现Callable的实现类
class NumThread implements Callable<Integer>{
    //2.实现call方法,将此线程需要执行的操作声明在call()中
    @Override
    public Integer call() throws Exception {
        int sum = 0;
        for (int i = 1; i < 100; i++) {
            if(i%2==0){
                System.out.println(i);
                sum += i;
            }
        }
        return sum;
    }
}

public class ThreadNew {
    public static void main(String[] args) {
        //3.创建Callable接口实现类的对象
        NumThread numThread = new NumThread();
        //4.将此Callable接口实现类的对象作为参数传递到FutureTask构造器中,创建FutureTask对象
        FutureTask<Integer> futureTask = new FutureTask(numThread);
        //5.将FutureTask的对象作为参数传递到Thread类的构造器中,创建Thread对象,并调用start()
        new Thread(futureTask).start();

        try {
            //6.获取Callable中Call方法的返回值
            Integer sum = futureTask.get();
            System.out.println("总和为"+sum);
        } catch (InterruptedException e) {
            e.printStackTrace();
        } catch (ExecutionException e) {
            e.printStackTrace();
        }
    }
}

2.5. 多线程的创建,方式四:线程池 ExecutorService

背景:经常创建和销毁、使用量特别大的资源、比如并发情况下的线程、对性能影响很大。

思路:提前创建好多个线程,放入线程池中,使用时直接获取,使用完放回池中。可以避免频繁创建销毁、实现重复利用。类似生活中的公共交通工具。

原理(个人理解):创建一个线程池时最重要的是设置核心线程数(corePoolSize)和最大线程数(maximumPoolSize)还有阻塞队列的大小(BlockingQueue<Runnable>),例如设置一个线程池的corePoolSize是3,maximumPoolSize是6,BlockingQueue是2。此时有9个任务提交过来,那么

  • 首先会通过线程池的execute或者submit方法提交执行任务
  • 提交第1个任务时,由于当前线程池中正在执行的任务为 0 ,小于 3(corePoolSize 指定),所以会创建一个线程用来执行提交的任务1;
  • 提交第 2, 3 个任务的时候,由于当前线程池中正在执行的任务数量小于等于 3 (corePoolSize 指定),所以会为每一个提交的任务创建一个线程来执行任务;
  • 当提交第4个任务的时候,由于当前正在执行的任务数量为 3 (因为每个线程任务执行时间为10s,所以提交第4个任务的时候,前面3个线程都还在执行中),此时会将第4个任务存放到 BlockingQueue 队列中等待执行;
  • 由于 BlockingQueue 队列的大小为 2 ,所以该队列中也就只能保存 2 个等待执行的任务,所以第5个任务也会保存到任务队列中;
  • 当提交第6个任务的时候,因为当前线程池正在执行的任务数量为3,BlockingQueue 队列中存储的任务数量也满了,这时会判断当前线程池中正在执行的任务的数量是否小于6(maximumPoolSize指定);
  • 如果小于 6 ,那么就会新创建一个线程来执行提交的任务 6;
  • 执行第7,8个任务的时候,也要判断当前线程池中正在执行的任务数是否小于6(maximumPoolSize指定),如果小于6,那么也会立即新建线程来执行这些提交的任务;
  • 此时,6个任务都已经提交完毕,那 BlockingQueue 队列中的等待 任务4 和 任务5 什么时候执行呢?
  • 当任务1执行完毕后,执行任务1的线程并没有被销毁掉,而是获取 BlockingQueue 中的任务4来执行;
  • 当任务2执行完毕后,执行任务2的线程也没有被销毁,而是获取 BlockingQueue 中的任务5来执行;
  • 那么第9个任务怎么办呢,如果提交9个任务时,任务1还没执行完毕,也就是线程池已经满了(maximumPoolSize指定),并且BlockingQueue队列也满了,那么任务9就会被设置的handler线程饱和策略处理,有以下4种:
  1. ThreadPoolExecutor.AbortPolicy:处理程序遭到拒绝,则直接抛出运行时异常 RejectedExecutionException。(默认策略)
  2. ThreadPoolExecutor.CallerRunsPolicy:调用者所在线程来运行该任务,此策略提供简单的反馈控制机制,能够减缓新任务的提交速度。
  3. ThreadPoolExecutor.DiscardPolicy:无法执行的任务将被删除。
  4. ThreadPoolExecutor.DiscardOldestPolicy:如果执行程序尚未关闭,则位于工作队列头部的任务将被删除,然后重新尝试执行任务(如果再次失败,则重复此过程)。

那么如何创建线程池呢?

首先, 可以通过Executors静态工厂构建线程池,但是一般不建议这样使用

阅读源码中可知,Executors的创建线程池的方法,创建出来的线程池都实现了ExecutorService接口。常用方法有以下几个:

  1. newFixedThreadPool(int nThreads):创建固定数目线程的线程池。
  2. newCachedThreadPool():创建一个可缓存的线程池,调用execute将重用以前构造的线程(如果线程可用)。如果没有可用的线程,则创建一个新线程并添加到线程池中。终止并从缓存中移除那些已有60秒钟未被使用的线程。
  3. newSingleThreadExecutor():创建一个单线程化的Executor。
  4. newScheduledThreadPool(int corePoolSize):创建一个支持定时及周期性的任务执行的线程池,多数情况下可用来替代Timer类。

例如:

class NumberThread implements Runnable {
    @Override
    public void run() {
        for (int i = 0; i < 100; i++) {
            if (i % 2 == 0) {
                System.out.println(Thread.currentThread().getName() + ":\t" + i);
            }
        }
    }
}
public class ThreadPool {
    public static void main(String[] args) {

        //1.提供指定线程数量的线程池
        ExecutorService service = Executors.newFixedThreadPool(10);
        ThreadPoolExecutor service1 = (ThreadPoolExecutor) service;
        //设置线程池的属性
        //   System.out.println(service.getClass());
        //   service1.setCorePoolSize(15);
        //   service1.setKeepAliveTime();

        //2.执行指定的线程的操作。需要提供实现Runnable接口或Callable接口实现类的对象。
        service.execute(new NumberThread()); //适合用于Runnable
        //        service.submit(); 适合适用于Callable
        //关闭线程池
        service.shutdown();
    }
}

但是:在开发中不允许使用Executors去创建线程池,而是通过ThreadPoolExecutor的方式,这样可以避免资源耗尽的风险。原因是:

  1. FixedThreadPool和SingleThreadPool:主要问题是堆积的请求处理队列可能会耗费非常大的内存,甚至OOM。
  2. CachedThreadPool和ScheduledThreadPool:主要问题是线程数最大数是Integer.MAX_VALUE,可能会创建数量非常多的线程,甚至OOM。

Executors为什么存在缺陷?

我们分析newFixedThreadPool的底层源码,真正导致OOM的其实是LinkedBlockingQueue.offer方法:

public static ExecutorService newFixedThreadPool(int nThreads) {
       return new ThreadPoolExecutor(nThreads, nThreads,
                                      0L, TimeUnit.MILLISECONDS,
                                      new LinkedBlockingQueue<Runnable>());
}

这里补充Java中的阻塞队列的知识: Java中的BlockingQueue主要有两种实现,分别是ArrayBlockingQueueLinkedBlockingQueue

  • ArrayBlockingQueue是一个用数组实现的有界阻塞队列,必须设置容量。
  • LinkedBlockingQueue是一个用链表实现的有界阻塞队列,容量可以选择进行设置,不设置的话,将是一个无边界的阻塞队列,最大长度为Integer.MAX_VALUE. 因此,这里就出现了一个问题:如果我们不设置 LinkedBlockingQueue 的容量的话,其默认容量将会是 Integer.MAX_VALUE。而newFixedThreadPool中创建LinkedBlockingQueue时,并未指定容量。此时,LinkedBlockingQueue就是一个无边界队列,对于一个无边界队列来说,是可以不断的向队列中加入任务的,这种情况就有可能因为任务过多导致内存溢出问题。

上面提到的问题主要体现在 newFixedThreadPoolnewSingleThreadExecutor 两个工厂方法上,并不是说 newCachedThreadPoolnewScheduledThreadPool 这两个方法就安全了,这两种方式创建的最大线程数可能是 Integer.MAX_VALUE,而创建这么多线程,必然就有可能导致 OOM。

那么,我们知道了要避免OOM的方法就是设置好线程池的大小和阻塞队列的大小,也就是直接使用new ThreadPoolExecutor()的方式。避免使用Executors创建线程池,主要是避免使用其中的默认实现。我们可以自己直接调用ThreadPoolExecutor的构造函数来自己创建线程池

通过ThreadPoolExecutor创建线程池 这是源码,我们逐一分析每个参数的含义:

public ThreadPoolExecutor(int corePoolSize,
                          int maximumPoolSize,
                          long keepAliveTime,
                          TimeUnit unit,
                          BlockingQueue<Runnable> workQueue) {
    this(corePoolSize, maximumPoolSize, keepAliveTime, unit, workQueue,
         Executors.defaultThreadFactory(), defaultHandler);
}

  1. corePoolSize(线程池基本大小,核心线程数)必须大于或等于0;
  2. maximumPoolSize(线程池最大大小,最大线程数)必须大于或等于1;
  3. maximumPoolSize必须大于或等于corePoolSize;
  4. keepAliveTime(线程存活保持时间)必须大于或等于0;
  5. workQueue(任务队列)不能为空;
  6. threadFactory(线程工厂)不能为空,默认为DefaultThreadFactory类
  7. handler(线程饱和策略)不能为空,默认策略为ThreadPoolExecutor.AbortPolicy。

使用的话,就拿上面的例子来说

public class ThreadPoolSerialTest {
    public static void main(String[] args) {
        //核心线程数
        int corePoolSize = 3;
        //最大线程数
        int maximumPoolSize = 6;
        //超过 corePoolSize 线程数量的线程最大空闲时间
        long keepAliveTime = 2;
        //以秒为时间单位
        TimeUnit unit = TimeUnit.SECONDS;
        //创建工作队列,用于存放提交的等待执行任务
        BlockingQueue<Runnable> workQueue = new ArrayBlockingQueue<Runnable>(2);
        ThreadPoolExecutor threadPoolExecutor = null;
        try {
            //创建线程池
            threadPoolExecutor = new ThreadPoolExecutor(corePoolSize,
                    maximumPoolSize,
                    keepAliveTime,
                    unit,
                    workQueue,
                    new ThreadPoolExecutor.AbortPolicy());

            //循环提交任务
            for (int i = 0; i < 9; i++) {
                //提交任务的索引
                final int index = (i + 1);
                threadPoolExecutor.submit(() -> {
                    //线程打印输出
                    System.out.println("大家好,我是线程:" + index);
                    try {
                        //模拟线程执行时间,10s
                        Thread.sleep(10000);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                });
                //每个任务提交后休眠500ms再提交下一个任务,用于保证提交顺序
                Thread.sleep(500);
            }
        } catch (InterruptedException e) {
            e.printStackTrace();
        } finally {
            threadPoolExecutor.shutdown();
        }
    }
}

以上,就是创建多线程的4种方式。

3. 线程的生命周期

  • JDk中用Thread.State类定义了线程的几种状态

想要实现多线程,必须在主线程中创建新的线程对象。Java语言使用Thread类及其子类的对象来表示线程,在他的一个完整的生命周期中通常要经历如下的五种状态

  1. 新建:当一个Thread类或其子类的对象被声明并创建时,新的线程对象处于新建状态。
  2. 就绪:处于新建状态的线程被start()后,将进入线程队列等待CPU时间片,此时它已具备了运行的条件,只是没分配到CPU资源。
  3. 运行:当就绪的线程被调度并获得CPU资源时,便进入运行状态,run()方法定义了线程的操作和功能。
  4. 阻塞:在某种特殊情况下,被认为挂起或执行输入输出操作时,让出CPU并临时中止自己的执行,进入阻塞状态。
  5. 死亡:线程完成了它的全部工作或线程被提前强制性的中止或出现异常倒置导致结束。

线程生命周期.jpg

4. Thread类的常用方法

4.1 静态方法

Thread类中的静态方法是通过Thread.方法名来调用的,那么问题来了,这个Thread指的是哪个Thread,是所在位置对应的那个Thread嘛?通过下面的例子可以知道,Thread类中的静态方法所操作的线程是“正在执行该静态方法的线程”,不一定是其所在位置的线程。为什么Thread类中要有静态方法,这样就能对CPU当前正在运行的线程进行操作。下面来看一下Thread类中的静态方法:

  • currentThread() : 返回对当前正在执行的线程对象的引用
  • sleep(long militime) : 线程休眠在指定的毫秒内让当前"正在执行的线程"休眠(暂停执行)。这个"正在执行的线程"是关键,指的是Thread.currentThread()返回的线程。根据JDK API的说法,"该线程不丢失任何监视器的所属权",简单说就是sleep代码上下文如果被加锁了,锁依然在,但是CPU资源会让出给其他线程。
  • yield() :线程让步,暂停当前执行的线程,并执行其他的线程。这个暂停是会放弃CPU资源的,并且放弃CPU的时间不确定,有可能刚放弃,就获得CPU资源了,也有可能放弃好一会儿,才会被CPU执行。

4.2 实例方法

Thread类中的实例方法是通过线程对象.方法名来调用的,和静态方法的区别就是:静态方法所操作的线程是“正在执行该静态方法的线程”,实例方法所操作的线程是调用该方法的线程对象。

  • join() :线程等待 在线程a中调用线程b的join() b.join(), 此时线程a进入阻塞状态, 直到线程b完全执行完以后, 线程a才结束阻塞状态
  • start() : 启动当前线程, 调用当前线程的run()方法
  • run() : 通常需要重写Thread类中的此方法, 将创建的线程要执行的操作声明在此方法中
  • getName() : 获取当前线程的名字
  • setName() : 设置当前线程的名字
  • getPriority(): 获取当前线程的优先级,线程默认优先级为5
  • setPriority(int):设置当前线程的优先级,最大为10
  • isAlive() :判断当前线程是否存活
  • interrupt():通知中断,① 如果线程处于被阻塞状态(例如处于sleep, wait, join 等状态),那么线程将立即退出被阻塞状态,并抛出一个InterruptedException异常。仅此而已。② 如果线程处于正常活动状态,那么会将该线程的中断标志设置为 true,仅此而已。被设置中断标志的线程将继续正常运行,不受影响。但是,如果是lock的lockInterruptibly()方法去获取锁没获取到,在等待中,使用此方法可以中断等待。

5. Object类关于线程的方法

  • wait():线程挂起一旦执行此方法,当前线程就会进入阻塞,一旦执行wait()会释放同步锁。
  • notify():随机唤醒一旦执行此方法,将会唤醒被wait的一个线程。如果有多个线程被wait,就唤醒优先度最高的。
  • notifyAll() :全部唤醒一旦执行此方法,就会唤醒所有被wait的线程

说明:这三个方法必须在同步代码块或同步方法中使用。

6. 线程的同步

背景:在多线程环境下,有多条语句操作共享数据/单条语句本身非原子操作时,就可能导致线程安全问题。 解决:把对共享数据的操作变成原子性操作,使用原子类AutomicInteger,或者加锁

引出synchronized关键字:

  • Java中每一个对象都可以成为一个监视器(Monitor), 该Monitor由一个锁(lock), 一个等待队列(waiting queue) , 一个入口队列(entry queue)组成。
  • 对于一个对象的方法, 如果没有synchronized关键字修饰, 该方法可以被任意数量的线程,在任意时刻调用。
    对于添加了synchronized关键字的方法,任意时刻只能被唯一的一个获得了对象实例锁的线程调用。
  • synchronized用于实现多线程的同步操作
  • 针对同一个线程,synchronized锁是可重入的 使用可以如下:

6.1. 同步代码块

synchronized(同步监视器){需要被同步的代码}

同步监视器:俗称,锁。任何一个类的对象都可以充当锁。但是所有的线程都必须共用一把锁,共用一个对象。

锁的选择:

  • 可以是多个线程操作的一个共享变量,例如余额,当一个线程操作余额变量时,其他线程会在入口队列中,必须等待其操作完才能进行操作。
  • this,当前类对象,会和其他线程操作的这个对象里的同步方法锁同一个对象

6.2. 同步方法

将所要同步的代码放到一个方法中,将方法声明为synchronized同步方法。之后可以在run()方法中调用同步方法。synchronized放于方法返回值之前。

要点:

  1. 同步方法仍然涉及到同步监视器,只是不需要我们显示的声明。
  2. 非静态的同步方法,同步监视器是:this。
  3. 静态的同步方法,同步监视器是:当前类本身。

同步代码块和同步方法的区别:

  1. 如果同步代码块的信号量是this的话,则和同步方法共用一把锁
  2. 同步方法直接在方法上加synchronized实现加锁,同步代码块则在方法内部加锁,很明显,同步方法锁的范围比较大,而同步代码块范围要小点,一般同步的范围越大,性能就越差,一般需要加锁进行同步的时候,肯定是范围越小越好,这样性能更好

例如下面代码,就是同步代码块的使用场景: 如果你两个set方法都声明为同步方法,那么在同一时间只能修改name或者id. 但是这两个是可以同时修改的,所以你需要同步代码块,将信号量分别设置成name和id

public class Test{
    private String name = "xiaoming";
    private String id = "0753";
    public void setName(String name) {
        synchornized(name) {
            this.name = name;
        }
    }
    public void setId(String id) {
        synchornized(id) {
            this.id = id;
        }
    }
}

7.用Lock实现线程间通信

synchronized被称为隐式锁,而Lock被称为显式锁。

7.1. Lock是什么

Lock 是 java.util.concurrent.locks 包下的接口,Lock 实现提供了比 synchronized 关键字 更广泛的锁操作,它能以更优雅的方式处理线程同步问题。

1.Lock和ReadWriteLock是两大锁的根接口,Lock代表实现类是ReentrantLock(可重入锁),ReadWriteLock(读写锁)的代表实现类是ReentrantReadWriteLock。

2.Lock 接口支持那些语义不同(重入、公平等)的锁规则,可以在非阻塞式结构的上下文(包括 hand-over-hand 和锁重排算法)中使用这些规则。

3.ReadWriteLock 接口以类似方式定义了一些读取者可以共享而写入者独占的锁。此包只提供了一个实现 ReentrantReadWriteLock。

4 .Lock是可重入锁,可中断锁,可以实现公平锁和读写锁,写锁为排它锁,读锁为共享锁。ReentrantLock也是一种排他锁

7.2. synchronized 与 Lock 的区别

1.synchronized是关键字,是JVM层面的,而Lock是一个接口,是JDK提供的API。

2.当一个线程获取了synchronized锁,其他线程便只能一直等待直至占有锁的线程释放锁。当发生以下情况之一线程才会释放锁: a.占有锁的线程执行完了该代码,然后释放对锁的占有。
b.占有锁线程执行发生异常,此时JVM会让线程自动释放锁。
c.占有锁线程进入 WAITING 状态从而释放锁,例如在该线程中调用wait()方法等。
但是如果占有锁的线程由于要等待IO或者因为其他原因(比如调用sleep方法)而使线程阻塞了,但是又没有释放锁,那么线程就只能一直等待,那么这时我们可能需要一种可以不让线程无期限的等待下去的方法,比如只等待一定的时间(tryLock(long time, TimeUnit unit)或者能被人为中断lockInterrup0tibly(),这种情况我们需要Lock。

3.当多个线程读写文件时,读操作和写操作会发生冲突现象,写操作和写操作也会发生冲突现象,但是读操作和读操作不会发生冲突现象,但是如果采用synchronized进行同步的话,就会导致当多个线程都只是进行读操作时也只有获取锁的线程才能进行读操作,其他线程只能等待锁释放后才能读,Lock则可以实现当多个线程都只是进行读操作时,线程之间不会发生冲突,例如:ReentrantReadWriteLock()。

4.可以通过Lock得知线程有没有成功获取到锁 (例如:ReentrantLock) ,但这个是synchronized无法办到的。

5.锁属性上的区别:synchronized是不可中断锁和非公平锁,ReentrantLock可以进行中断操作并别可以控制是否是公平锁。

6.synchronized能锁住方法和代码块,而Lock只能锁住代码块。

7.synchronized无法判断锁的状态,而Lock可以知道线程有没有拿到锁。

8.在性能上来说,如果竞争资源不激烈,两者的性能是差不多的,而当竞争资源非常激烈时,此时Lock的性能要远远优于synchronized。

7.3. ReentrantLock

实例化后可以使用其方法来获取锁和释放锁

private static Lock lock = new ReentrantLock();
  1. lock():用来获取锁。如果锁已被其他线程获取,则进行等待。采用Lock,必须主动去释放锁,并且在发生异常时,不会自动释放锁。因此一般来说,使用Lock必须在try{}catch{}块中进行,并且将释放锁的操作放在finally块中进行,以保证锁一定被被释放,防止死锁的发生。

  2. tryLock():用来尝试获取锁,但是该方法是有返回值的,如果获取成功,则返回true,如果获取失败(即锁已被其他线程获取),则返回false,也就说这个方法无论如何都会立即返回,在拿不到锁时也不会一直在那等待。

  3. tryLock(long time, TimeUnit unit):和tryLock()方法是类似的,只不过区别在于这个方法在拿不到锁时会等待一定的时间,在时间期限之内如果还拿不到锁,就返回false。如果如果一开始拿到锁或者在等待期间内拿到了锁,则返回true。

  4. lockInterruptibly(): 当通过这个方法去获取锁时,如果线程正在等待获取锁,则这个线程能够响应中断,即中断线程的等待状态。也就说,当两个线程同时通过lock.lockInterruptibly()想获取某个锁时,假若此时线程A获取到了锁,而线程B只有在等待,那么对线程B调用threadB.interrupt()方法能够中断线程B的等待过程。注意: 当一个线程获取了锁之后,是不会被interrupt()方法中断的。因为单独调用interrupt()方法不能中断正在运行过程中的线程,只能中断阻塞过程中的线程。因此当通过lockInterruptibly()方法获取某个锁时,如果不能获取到,只有进行等待的情况下,才可以响应中断。而用synchronized修饰的话,当一个线程处于等待某个锁的状态,是无法被中断的,只能一直等待下去。

  5. unlock():释放锁,放在finally块中进行,以保证锁一定被被释放,防止死锁的发生。

例子:

    private static Lock lock = new ReentrantLock();
 
    public static void main(String[] args) {
        lock.lock();
        try{
            System.out.println("获取锁成功!!");
        }catch(Exception e){
            e.printStackTrace();
        }finally{
            System.out.println("释放锁成功");
            lock.unlock();
        }
    }

7.4. Condition接口和newCondition()方法

private Condition condition = lock.newCondition();

synchronized关键字与wait()和notify/notifyAll()方法相结合可以实现等待/通知机制,ReentrantLock类也可以借助于Condition接口与newCondition()方法。

synchronized关键字在使用notify/notifyAll()方法进行通知时,被通知的线程是由JVM选择的,使用ReentrantLock类结合Condition实例可以实现“选择性通知”。

synchronized关键字就相当于整个Lock对象中只有一个Condition实例,所有的线程都注册在一个实例上。如果执行notifyAll()方法的话就会通知所有处于等待状态的线程,这样会造成很大的效率问题,而Condition可以实现多路通知功能也就是在一个Lock对象中可以创建多个Condition实例(即对象监视器),线程对象可以注册在指定的Condition中,Condition实例的signalAll()方法 只会唤醒注册在该Condition实例中的所有等待线程。从而可以有选择性的进行线程通知,在调度线程上更加灵活。

Condition接口的方法

    //使当前线程在接到信号或被中断之前一直处于等待状态。
    void await();
 
    //使当前线程在接到信号、被中断或到达指定等待时间之前一直处于等待状态。
    boolean await(long time, TimeUnit unit);
 
    //使当前线程在接到信号、被中断或到达指定等待时间之前一直处于等待状态。
    long awaitNanos(long nanosTimeout);
 
    //使当前线程在接到信号之前一直处于等待状态。
    void awaitUninterruptibly();
 
    //使当前线程在接到信号、被中断或到达指定最后期限之前一直处于等待状态。
    boolean awaitUntil(Date deadline);
 
    //唤醒一个等待线程。
    void signal();
 
    //唤醒所有等待线程。
    void signalAll();

7.5. 多个Condition实例实现等待/通知机制

一个Lock对象中可以创建多个Condition实例,调用某个实例的signalAll()方法 只会唤醒注册在该Condition实例中的所有等待线程。

public class LockTest {
 
    private Lock lock = new ReentrantLock();
    private Condition conditionA = lock.newCondition();
    private Condition conditionB = lock.newCondition();
 
    public void awaitA() {
        lock.lock();
        try {
            System.out.println("准备调用conditionA.await()方法,将该线程阻塞");
            conditionA.await();
            System.out.println(" awaitA 已被唤醒");
        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            lock.unlock();
        }
    }
 
    public void awaitB() {
        lock.lock();
        try {
            System.out.println("准备调用conditionB.await()方法,将该线程阻塞");
            conditionB.await();
            System.out.println(" awaitB 已被唤醒");
        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            lock.unlock();
        }
    }
 
    public void signalA() {
        lock.lock();
        try {
            System.out.println("准备唤醒 conditionA 下的所有线程");
            conditionA.signalAll();
        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            lock.unlock();
        }
    }
 
    public void signalB() {
        lock.lock();
        try {
            System.out.println("准备唤醒 conditionB 下的所有线程");
            conditionB.signalAll();
        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            lock.unlock();
        }
    }
 
}
public class Test {
 
    public static void main(String[] args) throws InterruptedException {
 
        LockTest lockTest = new LockTest();
 
        ThreadA a = new ThreadA(lockTest);
        a.setName("A");
        a.start();
 
        ThreadB b = new ThreadB(lockTest);
        b.setName("B");
        b.start();
 
        Thread.sleep(3000);
 
        lockTest.signalA();
 
    }
 
    static public class ThreadA extends Thread {
 
        private LockTest lockTest;
        public ThreadA(LockTest lockTest) {
            this.lockTest = lockTest;
        }
 
        @Override
        public void run() {
            lockTest.awaitA();
        }
    }
 
    static public class ThreadB extends Thread {
 
        private LockTest lockTest;
        public ThreadB(LockTest lockTest) {
            this.lockTest = lockTest;
        }
 
        @Override
        public void run() {
            lockTest.awaitB();
        }
    }
}

输出:

准备调用conditionA.await()方法,将该线程阻塞
准备调用conditionB.await()方法,将该线程阻塞
准备唤醒 conditionA 下的所有线程
 awaitA 已被唤醒

7.6. ReadWriteLock 接口及其 实现类 ReentrantReadWriteLock

public interface ReadWriteLock {
    // 读锁
    Lock readLock();
    // 写锁
    Lock writeLock();
}

ReentrantLock是一种排他锁,同一时刻只允许一个线程访问,ReadWriteLock 接口的实现类 ReentrantReadWriteLock 读写锁提供了两个方法:readLock()和writeLock()用来获取读锁和写锁,也就是说将文件的读写操作分开,分成2个锁来分配给线程,从而使得多个线程可以同时进行读操作。

读写锁维护了两个锁,一个是读操作相关的锁也称为共享锁,一个是写操作相关的锁也称为排他锁。通过分离读锁和写锁,其并发性比一般排他锁有了很大提升。

多个读锁之间不互斥,读锁与写锁互斥,写锁与写锁互斥(只要出现写操作的过程就是互斥的)。在没有线程进行写操作时,进行读取操作的多个线程都可以获取读锁,而进行写入操作的线程只有在获取写锁后才能进行写操作。即多个线程可以同时进行读取操作,但是同一时刻只允许一个线程进行写入操作。

读锁
public class ReentrantReadWriteLockTest {
 
    private ReentrantReadWriteLock reentrantReadWriteLock = new ReentrantReadWriteLock();
 
    public static void main(String[] args) {
        final ReentrantReadWriteLockTest test = new ReentrantReadWriteLockTest();
 
        new Thread() {
            public void run() {
                test.get(Thread.currentThread());
            }
        }.start();
 
        new Thread() {
            public void run() {
                test.get(Thread.currentThread());
            }
        }.start();
    }
 
    public void get(Thread thread){
        reentrantReadWriteLock.readLock().lock();
 
        try {
 
            for (int i=0;i<10;i++){
                System.out.println(thread.getName() + "正在进行读操作");
                Thread.sleep(1000);
            }
 
            System.out.println(thread.getName() + "读操作完毕");
        } catch (InterruptedException e) {
            e.printStackTrace();
        } finally {
            reentrantReadWriteLock.readLock().unlock();
        }
    }
}

输出

Thread-0正在进行读操作
Thread-1正在进行读操作
Thread-1正在进行读操作
Thread-0正在进行读操作
Thread-1正在进行读操作
Thread-0正在进行读操作
Thread-1正在进行读操作
Thread-0正在进行读操作
Thread-0正在进行读操作
。。。。。。。。。。。。。。。
Thread-1读操作完毕
Thread-0读操作完毕

得出:多个线程可以同时获得读锁

读写互斥
public class ReentrantReadWriteLockTest {
 
    public static void main(String[] args) {
        LockTest lockTest = new LockTest();
 
        ReadThread readThread = new ReadThread(lockTest);
        ReadThread readThread2 = new ReadThread(lockTest);
        WriteThread writeThread = new WriteThread(lockTest);
        ReadThread readThread3 = new ReadThread(lockTest);
        readThread.start();
        readThread2.start();
        writeThread.start();
 
        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        readThread3.start();
    }
 
 
    static public class LockTest {
        private ReentrantReadWriteLock lock = new ReentrantReadWriteLock();
 
        public void read(Thread thread) {
            lock.readLock().lock();
            try {
                for (int i=0;i<5;i++){
                    System.out.println(thread.getName() + "正在进行读操作");
                    Thread.sleep(1000);
                }
                System.out.println(thread.getName() + "读操作完毕");
            } catch (Exception e) {
                e.printStackTrace();
            } finally {
                lock.readLock().unlock();
            }
        }
 
        public void write(Thread thread) {
            lock.writeLock().lock();
            try {
                for (int i=0;i<5;i++){
                    System.out.println(thread.getName() + "正在进行写操作");
                    Thread.sleep(1000);
                }
                System.out.println(thread.getName() + "写操作完毕");
            } catch (InterruptedException e) {
                e.printStackTrace();
            } finally {
                lock.writeLock().unlock();
            }
        }
    }
 
    static public class ReadThread extends Thread {
        private LockTest lockTest;
 
        public ReadThread(LockTest lockTest) {
            this.lockTest = lockTest;
        }
 
        @Override
        public void run() {
            lockTest.read(Thread.currentThread());
        }
    }
 
 
    static public class WriteThread extends Thread {
        private LockTest lockTest;
 
        public WriteThread(LockTest lockTest) {
            this.lockTest = lockTest;
        }
 
        @Override
        public void run() {
            lockTest.write(Thread.currentThread());
        }
    }
}

输出

Thread-0正在进行读操作
Thread-1正在进行读操作
Thread-0正在进行读操作
Thread-1正在进行读操作
Thread-1正在进行读操作
Thread-0正在进行读操作
Thread-1正在进行读操作
Thread-0正在进行读操作
Thread-0正在进行读操作
Thread-1正在进行读操作
Thread-1读操作完毕
Thread-0读操作完毕
Thread-2正在进行写操作
Thread-2正在进行写操作
Thread-2正在进行写操作
Thread-2正在进行写操作
Thread-2正在进行写操作
Thread-2写操作完毕
Thread-3正在进行读操作
Thread-3正在进行读操作
Thread-3正在进行读操作
Thread-3正在进行读操作
Thread-3正在进行读操作
Thread-3读操作完毕

由此可以看出,读锁可以共享,写锁只有在所有读锁释放后才能执行,但是当写锁在阻塞和获取过程中,之后的读锁也会阻塞,需要等到写锁释放后才能获取。

8. ThreadLocal

8.1. ThreadLocal是什么

从名字我们就可以看到ThreadLocal 叫做本地线程变量,意思是说,ThreadLocal 中填充的的是当前线程的变量,该变量对其他线程而言是封闭且隔离的,ThreadLocal 为变量在每个线程中创建了一个副本,这样每个线程都可以访问自己内部的副本变量。

  • 1、在进行对象跨层传递的时候,使用ThreadLocal可以避免多次传递,打破层次间的约束。
  • 2、线程间数据隔离
  • 3、进行事务操作,用于存储线程事务信息。
  • 4、数据库连接,Session会话管理。

8.2. ThreadLocal怎么用

举个简单的例子,调用无参构造器实例化ThreadLocal对象,然后调用实例方法set(T t)存变量,get()方法获取变量。

public class TestThreadLocal {
    private static final ThreadLocal<Integer> local = new ThreadLocal<Integer>() {
        @Override
        protected Integer initialValue() {
            return 0;
        }
    };

    public static void main(String[] args) {
        for (int i = 0; i < 3; i++) {
            new Thread(new MyThread(i)).start();
        }
    }

    static class MyThread implements Runnable {
        private int index;

        public MyThread(int index) {
            this.index = index;
        }

        public void run() {
            System.out.println("线程" + index + "的初始value:" + local.get());
            for (int i = 0; i < 10; i++) {
                local.set(local.get() + i);
            }
            System.out.println("线程" + index + "的累加value:" + local.get());
        }
    }
}

执行结果为:

线程0的初始value:0

线程2的初始value:0

线程2的累加value:45

线程1的初始value0

线程0的累加value:45

线程1的累加value:45

可以看出,各个线程的value值是相互独立的,本线程的累加操作不会影响到其他线程的值,真正达到了线程内部隔离的效果。

8.3. ThreadLocal可能发生内存泄漏

什么是内存泄漏

内存泄漏指的是,当某一个对象不再有用的时候,占用的内存却不能被回收,这就叫作内存泄漏。

因为通常情况下,如果一个对象不再有用,那么我们的垃圾回收器 GC,就应该把这部分内存给清理掉。这样的话,就可以让这部分内存后续重新分配到其他的地方去使用;否则,如果对象没有用,但一直不能被回收,这样的垃圾对象如果积累的越来越多,则会导致我们可用的内存越来越少,最后发生内存不够用的 OOM 错误。

下面粗略的分析一下,在 ThreadLocal 中这样的内存泄漏是如何发生的。

每一个 Thread 都有一个 ThreadLocal.ThreadLocalMap 这样的类型变量,该变量的名字叫作 threadLocals。线程在访问了 ThreadLocal 之后,都会在它的 ThreadLocalMap 里面的 Entry 中去维护该 ThreadLocal 变量与具体实例的映射。

也就是每个Thread维护一个ThreadLocalMap映射表,这个映射表的key是ThreadLocal实例本身,value是真正需要存储的Object。

而如果ThreadLocal 已经不被使用了,线程又一直存活、不终止的话,那ThreadLocalMap 里面的 Entry就一直在,就导致了内存泄漏。

如何避免内存泄漏

调用 ThreadLocal 的 remove 方法,在使用完了 ThreadLocal 之后,我们应该手动去调用它的 remove 方法,目的是防止内存泄漏的发生。

很多情况下需要使用者手动调用ThreadLocal的remove函数,手动删除不再需要的ThreadLocal,防止内存泄露。所以JDK建议将ThreadLocal变量定义成private static的,这样的话ThreadLocal的生命周期就更长,由于一直存在ThreadLocal的强引用,所以ThreadLocal也就不会被回收,也就能保证任何时候都能根据ThreadLocal的弱引用访问到Entry的value值,然后remove它,防止内存泄露。