Java基础整理:多线程相关

线程的创建方法

Java使用Thread类代表线程,所有的线程对象都必须是Thread类或其子类的实例。

继承Thread类创建线程

  1. 定义Thread类的子类,并重写该类的run()方法,该方法的方法体就是线程需要完成的任务,run()方法也称为线程执行体。
  2. 创建Thread子类的实例,也就是创建了线程对象
  3. 启动线程,即调用线程的start()方法
public class MyThread extends Thread{//继承Thread类
  public void run(){
  //重写run方法
  }
}
public class Main {
  public static void main(String[] args){
    new MyThread().start();//创建并启动线程
  }
}

实现Runnable接口创建线程

  1. 定义Runnable接口的实现类,一样要重写run()方法,这个run()方法和Thread中的run()方法一样是线程的执行体
  2. 创建Runnable实现类的实例,并用这个实例作为Thread的target来创建Thread对象,这个Thread对象才是真正的线程对象
  3. 第三步依然是通过调用线程对象的start()方法来启动线程
public class MyThread2 implements Runnable {//实现Runnable接口
  public void run(){
  //重写run方法
  }
}
public class Main {
  public static void main(String[] args){
    //创建并启动线程
    MyThread2 myThread=new MyThread2();
    Thread thread=new Thread(myThread);
    thread().start();
    //或者    new Thread(new MyThread2()).start();
  }
}

使用Callable和Future创建线程

  1. 创建Callable接口的实现类,并重写call()方法,然后创建该实现类的实例(从java8开始可以直接使用Lambda表达式创建Callable对象)。
  2. 使用FutureTask类来包装Callable对象(由Callable创建一个FutureTask对象),该FutureTask对象封装了Callable对象的call()方法的返回值
  3. 使用FutureTask对象作为Thread对象的target创建并启动线程(因为FutureTask实现了Runnable接口)
  4. 调用FutureTask对象的get()方法来获得子线程执行结束后的返回值
public class Main {
  public static void main(String[] args){
   MyThread3 th = new MyThread3();  //callable接口实现类实例
     //使用FutureTask类来包装Callable对象
   FutureTask<Integer> future = new FutureTask<Integer>(th);
   Thread oneThread = new Thread(oneTask); //用FutureTask对象实例化thread对象
   oneThread.start();

    try{
    System.out.println("子线程的返回值:"+future.get());//get()方法会阻塞,直到子线程执行结束才返回
    }catch(Exception e){
    ex.printStackTrace();
   }
  }
}

三种方法对比

(1)实现Runnable和实现Callable接口的方式基本相同,可以把这两种方式归为一种,这种实现接口的方式与直接继承Thread类的方法之间的差别如下:

  1. 线程只是实现Runnable或实现Callable接口,还可以继承其他类。这种方式下,多个线程可以共享一个target对象,非常适合多线程处理同一份资源的情形。但是编程稍微复杂,如果需要访问当前线程,必须调用Thread.currentThread()方法。
  2. 继承Thread类的线程类不能再继承其他父类(Java单继承决定)。使用这种方式创建线程类时,每次都会创建一个新的线程类对象,因此多线程之间无法共享线程类的实例变量。

注:一般推荐采用实现接口的方式来创建多线程

(2)Callable与Runnable区别
Callable执行call()方法可以有返回值,并且可以声明抛出异常

boolean cancel(boolean mayInterruptIfRunning):视图取消该Future里面关联的Callable任务
V get():返回Callable里call()方法的返回值,调用这个方法会导致程序阻塞,必须等到子线程结束后才会得到返回值
V get(long timeout,TimeUnit unit):返回Callable里call()方法的返回值,最多阻塞timeout时间,经过指定时间没有返回抛出TimeoutException
boolean isDone():若Callable任务完成,返回True
boolean isCancelled():如果在Callable任务正常完成前被取消,返回True

线程的生命周期

Java基础整理:多线程相关
注:

  1. 已经死亡的线程无法调用start()方法使其重新启动。
  2. 只能对新建(new)的线程调用start()进入就绪状态,且直客调用一次。就绪状态不等于立刻运行,何时运行由系统线程调度决定。
  3. 运行态的线程受系统线程调度并发执行。

线程状态控制方法

sleep()

Thread类的静态方法,用于让当前正在执行的线程暂停一段时间,并进入阻塞状态,直到休眠时间结束,才进入就绪态。sleep将给其他线程执行机会,不理会其优先级。

Thread.sleep(1000); //暂停1000ms

注:与Object类方法Wait()的区别:

  1. sleep,wait调用后都会暂停当前线程并让出cpu的执行时间,但不同的是sleep不会释放当前持有的对象的锁资源,到时间后会继续执行,而wait会放弃所有锁并需要notify/notifyAll后重新获取到对象锁资源后才能继续执行。
  2. sleep可以在任何地方使用,而wait只能在同步方法或者同步块中使用。
  3. sleep需要捕获或者抛出异常,而wait/notify/notifyAll不需要。

yield()

Thread类的静态方法,也可以当前正在执行的线程暂停,但不进入阻塞状态,而是直接转入就绪状态。当某线程调用yield方法暂停之后,只有优先级大于等于该线程并处于就绪状态的线程可以运行。因此,可能出现某线程调用yield方法暂停后,调度器又将其调度出来重新执行的情况。
此外,与sleep不同,yield没有声明抛出异常。

join()

t.join()方法阻塞调用此方法的线程(calling thread),直到线程t完成,此线程再继续;通常用于在main()主线程内,等待其它线程完成再结束main()主线程。

死锁

死锁是指两个或两个以上的进程在执行过程中,因争夺资源而造成的一种互相等待的现象,(T1占用A,想要B,T2占用B想要A)若无外力作用,它们都将无法推进下去。死锁会让你的程序挂起无法完成任务,死锁的发生必须满足以下四个条件:
• 互斥条件:一个资源每次只能被一个进程使用。
• 请求与保持条件:一个进程因请求资源而阻塞时,对已获得的资源保持不放。
• 不剥夺条件:进程已获得的资源,在末使用完之前,不能强行剥夺。
• 循环等待条件:若干进程之间形成一种头尾相接的循环等待资源关系。
避免死锁最简单的方法就是阻止循环等待条件,将系统中所有的资源设置标志位、排序,规定所有的进程申请资源必须以一定的顺序(升序或降序)做操作来避免死锁。

线程同步

Synchronized

特点

  1. java中的一个关键字,是Java的内置特性,在JVM层面实现了对临界资源的同步互斥访问。
  2. 锁的释放由虚拟机来完成,不用人工干预,不过此即使缺点也是优点,优点是不用担心会造成死锁,缺点是由可能获取到锁的线程阻塞(由于要等待IO或者其他原因)之后其他线程会一直等待,性能不高。
    JVM释放锁的条件:
    1)获取锁的线程执行完了该代码块,然后线程释放对锁的占有;
    2)线程执行发生异常,此时JVM会让线程自动释放锁。
  3. 同一时刻不管是读还是写都只能有一个线程对共享资源操作,其他线程只能等待。
  4. 不可中断,尝试获取锁时不能中途取消。
  5. 不公平,可重入

原理

monitor对象存在于每个Java对象的对象头中(存储的指针的指向),synchronized锁便是通过这种方式获取锁的,也是为什么Java中任意对象可以作为锁的原因。当一个 monitor 被某个线程持有后,它便处于锁定状态。在Java虚拟机(HotSpot)中,monitor是由ObjectMonitor实现的,ObjectMonitor中有两个队列,_WaitSet 和 _EntryList,用来保存ObjectWaiter对象列表( 每个等待锁的线程都会被封装成ObjectWaiter对象),_owner指向持有ObjectMonitor对象的线程,当多个线程同时访问一段同步代码时,首先会进入 _EntryList 集合,当线程获取到对象的monitor 后进入 _Owner 区域并把monitor中的owner变量设置为当前线程同时monitor中的计数器count加1,若线程调用 wait() 方法,将释放当前持有的monitor,owner变量恢复为null,count自减1,同时该线程进入 WaitSe t集合中等待被唤醒。若当前线程执行完毕也将释放monitor(锁)并复位变量的值,以便其他线程进入获取monitor(锁)。
Java基础整理:多线程相关
Contention List:所有请求锁的线程将被首先放置到该竞争队列

Entry List:Contention List中那些有资格成为候选人的线程被移到Entry List

Wait Set:那些调用wait方法被阻塞的线程被放置到Wait Set

OnDeck:任何时刻最多只能有一个线程正在竞争锁,该线程称为OnDeck

Owner:获得锁的线程称为Owner

应用方法

synchronized关键字最主要有以下3种应用方式(锁住的总是对象)

  1. 修饰实例方法,作用于当前实例加锁,进入同步代码前要获得当前实例的锁
  2. 修饰静态方法,作用于当前类对象加锁,进入同步代码前要获得当前类对象的锁
  3. 修饰代码块,指定加锁对象,synchronized(instance)对给定对象加锁,进入同步代码库前要获得给定对象的锁
// 修饰实例方法
public synchronized void method()
{
   // todo
}

// 修饰静态方法
public synchronized static void method() {
   // todo
}

// 修饰代码块
public static void test() {
		//类锁
		synchronized (A.class) {
			System.out.println("haha");
		}
	}
	public void test2() {
		//实例锁
		synchronized (this) {
			System.out.println("haha");
		}
	}

等待唤醒机制

所谓等待唤醒机制主要指的是notify/notifyAll和wait方法,在使用这3个方法时,必须处于synchronized代码块或者synchronized方法中,否则就会抛出IllegalMonitorStateException异常,这是因为调用这几个方法前必须拿到当前对象的监视器monitor对象,也就是说notify/notifyAll和wait方法依赖于monitor对象,在前面的分析中,我们知道monitor 存在于对象头的Mark Word 中(存储monitor引用指针),而synchronized关键字可以获取 monitor,这也就是为什么notify/notifyAll和wait方法必须在synchronized代码块或者synchronized方法调用的原因。

Lock

特点

  1. Lock是一个类
  2. Lock必须手动释放锁(Finally()中),不然容易造成线程死锁
  3. 通过Lock可以知道线程有没有成功获取到锁。这个是synchronized无法办到的。Lock也可以使得多个线程都只是进行读操作时,线程之间不会发生冲突。
  4. 可公平,可重入

其中,常用的ReentrantLock(可重入锁)特点具体如下:

  1. 等待可中断:当持有锁的线程长期不释放锁时,正在等待的线程可以选择放弃等待,改为处理其他事情,它对处理执行时间非常上的同步块很有帮助。
  2. 可实现公平锁:多个线程在等待同一个锁时,必须按照申请锁的时间顺序排队等待,而非公平锁则不保证这点,在锁释放时,任何一个等待锁的线程都有机会获得锁。synchronized中的锁时非公平锁,ReentrantLock默认情况下也是非公平锁,但可以通过构造方法ReentrantLock(ture)来要求使用公平锁。
  3. 锁可以绑定多个条件:ReentrantLock对象可以同时绑定多个Condition对象(名曰:条件变量或条件队列),而在synchronized中,锁对象的wait()和notify()或notifyAll()方法可以实现一个隐含条件,但如果要和多于一个的条件关联的时候,就不得不额外地添加一个锁,而ReentrantLock则无需这么做,只需要多次调用newCondition()方法即可。而且我们还可以通过绑定Condition对象来判断当前线程通知的是哪些线程(即与Condition对象绑定在一起的其他线程)。

应用方法

private final ReentrantLock lock = new ReentrantLock();

lock.lock();
try
{
	//需要同步的代码...
}
finally{
	lock.unlock();
}

等待唤醒机制

private final ReentrantLock lock = new ReentrantLock();
private final Condition cond = lock.newCondition();

lock.lock();
...
cond.await();
...
cond.signalAll();
...
lock.unlock();

线程池

作用

如果并发的线程数量很多,并且每个线程都是执行一个时间很短的任务就结束了,这样频繁创建线程就会大大降低系统的效率,因为频繁创建线程和销毁线程需要时间。JAVA通过线程池使得线程可以复用,就是执行完一个任务,并不被销毁,而是可以继续执行其他的任务。

执行逻辑

  1. 如果当前worker(线程池中的线程被封装成worker)数量小于corePoolSize,则新建一个worker并把当前任务分配给该worker线程,成功则返回。
  2. 如果第一步失败,则尝试把任务放入阻塞队列,如果成功则返回,且该任务会等待空闲线程将其取出去执行。
  3. 如果第二步失败(一般是阻塞队列满了),则判断如果当前worker数量小于maximumPoolSize,则新建一个worker并把当前任务分配给该worker线程,成功则返回。
  4. 如果第三步失败,则调用拒绝策略处理该任务。

当线程池中的线程数量大于 corePoolSize时,如果某线程空闲时间超过keepAliveTime,线程将被终止,直至线程池中的线程数目不大于corePoolSize;如果允许为核心池中的线程设置存活时间,那么核心池中的线程空闲时间超过keepAliveTime,线程也会被终止。

线程的启动和关闭

ThreadPoolExecutor → AbstractExecutorService(抽象类)→ ExecutorService(接口)→ Executor(顶层接口)

默认情况下,创建线程池之后,线程池中是没有线程的,需要提交任务之后才会创建线程。

  1. execute()方法实际上是Executor中声明的方法,在ThreadPoolExecutor进行了具体的实现,,通过这个方法可以向线程池提交一个任务,交由线程池去执行。
  2. submit()方法是在ExecutorService中声明的方法,在AbstractExecutorService就已经有了具体的实现,在ThreadPoolExecutor中并没有对其进行重写,这个方法也是用来向线程池提交任务的,但是它和execute()方法不同,它能够返回任务执行的结果(返回持有计算结果的Future对象)
  3. shutdown():不会立即终止线程池,而是要等所有任务缓存队列中的任务都执行完后才终止,但再也不会接受新的任务
  4. shutdownNow():立即终止线程池,并尝试打断正在执行的任务,并且清空任务缓存队列,返回尚未执行的任务

线程池的种类

  1. newCachedThreadPool 方法,它创建了一个可缓存的线程池,如果线程池的长度超过处理需要,它可灵活回收空闲线程,若无可回收,则新建线程。
  2. newScheduledThreadPool 方法,它创建了一个定长线程池,支持定时及周期性的任务执行。
  3. newFixedThreadPool 方法,它创建了一个定长线程池,可以控制线程最大并发数,超出的线程会在队列中等待。
  4. newSingleThreadExecutor 方法,它创建了一个单线程化的线程池,它只会用唯一的工作线程来执行任务,保证所有的任务按照指定的顺序(FIFO, LIFO, 优先级)来执行的。

合理配置线程池大小

  1. 如果是CPU密集型任务,就需要尽量压榨CPU,参考值可以设为 NCPU+1
    因为CPU密集型任务CPU的使用率很高,若开过多的线程,只能增加线程上下文的切换次数,带来额外的开销
  2. 如果是IO密集型任务,参考值可以设置为2*NCPU
    IO密集型CPU使用率不高,可以让CPU等待IO的时候处理别的任务,充分利用cpu时间

线程等待时间所占比例越高,需要越多线程。线程CPU时间所占比例越高,需要越少线程。
比如平均每个线程CPU运行时间为0.5s,而线程等待时间(非CPU运行时间,比如IO)为1.5s,CPU核心数为8,那么根据上面这个公式估算得到:((0.5+1.5)/0.5)8=32。
这个公式进一步转化为:
最佳线程数目 = (线程等待时间与线程CPU时间之比 + 1)
CPU数目

ThreadLocal

概念

用于避免多个线程对共享资源(变量)的竞争
当使用ThreadLocal维护变量的时候 为每一个使用该变量的线程提供一个独立的变量副本,即每个线程内部都会有一个该变量,这样同时多个线程访问该变量并不会彼此相互影响,因此他们使用的都是自己从内存中拷贝过来的变量的副本, 这样就不存在线程安全问题,也不会影响程序的执行性能。
但是要注意,虽然ThreadLocal能够解决上面说的问题,但是由于在每个线程中都创建了副本,所以要考虑它对资源的消耗,比如内存的占用会比不使用ThreadLocal要大。

原理

Thread 在内部是通过ThreadLocalMap来维护ThreadLocal变量表, 在Thread类中有一个threadLocals 变量,是ThreadLocalMap类型的,它就是为每一个线程来存储自身的ThreadLocal变量的, ThreadLocalMap是ThreadLocal类的一个内部类,这个Map里面的最小的存储单位是一个Entry, 它使用ThreadLocal对象作为key, 要存储的变量值作为 value,所以在每一个线程里面,可能存在着多个ThreadLocal变量

用途

数据库连接、Session管理

public class ConnectionManager {
    private static ThreadLocal<Connection> connThreadLocal = new ThreadLocal<Connection>();
    public static Connection getConnection() {
        if(connThreadLocal.get() != null)
            return connThreadLocal.get();
        
        //获取一个连接并设置到当前线程变量中
        Connection conn = getConnection();
        connThreadLocal.set(conn);
        return conn;
    }
}

弱引用问题

当线程没有结束,但是ThreadLocal已经被回收,则可能导致线程中存在ThreadLocalMap<null, Object>的键值对,造成内存泄露。(ThreadLocal被回收,ThreadLocal关联的线程共享变量还存在)。
虽然ThreadLocal的get,set方法可以清除ThreadLocalMap中key为null的value,但是get,set方法在内存泄露后并不会必然调用,所以为了防止此类情况的出现,我们有两种手段。
1、使用完线程共享变量后,显示调用ThreadLocalMap.remove方法清除线程共享变量;
2、JDK建议ThreadLocal定义为private static,这样ThreadLocal的弱引用问题则不存在了。

volatile

并发编程中的三个特性

  1. 原子性
     即一个操作或者多个操作 要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行。一个很经典的例子就是银行账户转账问题
  2. 可见性
     可见性是指当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值。
  3. 有序性
     即程序执行的顺序按照代码的先后顺序执行。

volatile关键字的两层语义

1) 保证了不同线程对这个变量进行操作时的可见性,即一个线程修改了某个变量的值,这新值对其他线程来说是立即可见的。
原理:

  1. 使用volatile关键字会强制将修改的值立即写入主存;
  2. 使用volatile关键字的话,当线程2进行修改时,会导致线程1的工作内存中缓存变量stop的缓存行无效(反映到硬件层的话,就是CPU的L1或者L2缓存中对应的缓存行无效);
  3. 由于线程1的工作内存中缓存变量stop的缓存行无效,所以线程1再次读取变量stop的值时会去主存读取。那么在线程2修改stop值时(当然这里包括2个操作,修改线程2工作内存中的值,然后将修改后的值写入内存),会使得线程1的工作内存中缓存变量stop的缓存行无效,然后线程1读取时,发现自己的缓存行无效,它会等待缓存行对应的主存地址被更新之后,然后去对应的主存读取最新的值。

2) 禁止进行指令重排序,即:

  1. 当程序执行到volatile变量的读操作或者写操作时,在其前面的操作的更改肯定全部已经进行,且结果已经对后面的操作可见;在其后面的操作肯定还没有进行;
  2. 在进行指令优化时,不能将在对volatile变量访问的语句放在其后面执行,也不能把volatile变量后面的语句放到其前面执行。
    底层原理: 加入volatile关键字时,会多出一个lock前缀指令,其相当于一个内存屏障(也成内存栅栏)

volatile不保证原子性

i++操作的非原子性:当线程执行这个语句时,1)会先从主存当中读取i的值,然后复制一份到高速缓存当中,2)然后CPU执行指令对i进行加1操作,然后将数据写入高速缓存,3)最后将高速缓存中i最新的值刷新到主存当中。

volatile只能保证每次读取的是最新的值,但是volatile没办法保证对变量的操作的原子性。

例:假如某个时刻变量inc的值为10
  线程1对变量进行自增操作,线程1先读取了变量inc的原始值,然后线程1被阻塞了;
  然后线程2对变量进行自增操作,线程2也去读取变量inc的原始值,由于线程1只是对变量inc进行读取操作,而没有对变量进行修改操作,所以不会导致线程2的工作内存中缓存变量inc的缓存行无效,所以线程2会直接去主存读取inc的值,发现inc的值时10,然后进行加1操作,并把11写入工作内存,最后写入主存。
  然后线程1接着进行加1操作,由于已经读取了inc的值,注意此时在线程1的工作内存中inc的值仍然为10,所以线程1对inc进行加1操作后inc的值为11,然后将11写入工作内存,最后写入主存。
  那么两个线程分别进行了一次自增操作后,inc只增加了1。

fork/join 模型

forkjoinpool支持将一个任务拆分成多个小任务并行计算,再把多个小任务的结果并成总的计算结果
举例来说:对超过1000万个元素的数组进行排序,这种任务本身可以并发执行,但如何拆解成小任务需要在任务执行的过程中动态拆分。这样,大任务可以拆成小任务,小任务还可以继续拆成更小的任务,最后把任务的结果汇总合并,得到最终结果,这种模型就是Fork/Join模型。

class SumTask extends RecursiveTask<Long> {
    static final int THRESHOLD = 100;
    long[] array;
    int start;
    int end;

    SumTask(long[] array, int start, int end) {
    this.array = array;
        this.start = start;
        this.end = end;
    }
    protected Long compute() {
        if (end - start <= THRESHOLD) {
            // 如果任务足够小,直接计算:
            long sum = 0;
            for (int i = start; i < end; i++) {
                sum += array[i];
            }
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
            }
            return sum;
        }
        // 任务太大,一分为二:
        int middle = (end + start) / 2;
        SumTask subtask1 = new SumTask(this.array, start, middle); //建两个小任务
        SumTask subtask2 = new SumTask(this.array, middle, end);
        invokeAll(subtask1, subtask2); //N-1个任务会使用fork()交给其它线程执行,但是,它还会留一个任务自己执行
        Long subresult1 = subtask1.join(); 
        Long subresult2 = subtask2.join();
        Long result = subresult1 + subresult2;
);
        return result;
    }
}