萤火小屋

优律的知识库

  • 首页
  • 归档
  • 分类
  • 标签
  • 留言
  • 关于

  • 搜索
双指针 力扣 动态代理 Git YAML SpringBoot SpringMVC 回溯算法 分治算法 归并排序 快排 手撕 事务 MySQL索引 MySQL 小技巧 Spring Framework Spring 动态规划 Linux Android 贪心算法 操作系统 进程调度模拟 IPv6 数据库 计算机组成原理 计算机基础 栈 Java 静态路由 路由器 交换机 数字通信 网络工程 计算机网络 Web http 大学学习技巧 程序设计 算法

Java多线程和并发

发表于 2021-12-09 | 分类于 Java SE | 0 | 阅读次数 111

1 Java线程的创建

1.1 继承Thread类

Thread类本质上是实现了Runnable接口的一个实例,代表一个线程的实例。启动线程的唯一方法就是通过调用Thread类的start()实例方法。start()方法是一个native方法,它将启动一个新线程,并执行run()方法。

线程类:

public class MyThread extends Thread {
	public void run() {
		System.out.println("新的线程跑起来啦~");
	}
}

测试类:

MyThread myThread = new MyThread();
myThread.start();

1.2 实现Runnable接口

如果自己的类已经extends另一个类,就无法直接extends Thread,此时可以实现一个Runnable接口。

线程类:

public class RunnableImpl extends OtherClass implements Runnable {
	// 重写run方法
	@Override
	public void run() {
		System.out.println("新的线程跑起来啦~");
	}
}

测试方法:

// 启动新线程,需要首先实例化一个Thread,并传入自己的Runnable实现类的实例
@Test
public void testRunnable() {
	RunnableImpl runnableImpl = new RunnableImpl();
	Thread thread = new Thread(runnableImpl);
	thread.start();
}

然而在Thread类中,当传入一个值给Thread类属性Runnable target后,Thread的run()方法就会调用target的run()方法

// Thread类target属性源码(保留了源码注释)
/* What will be run. */
private Runnable target;

// ...

// Thread类run方法源码
public void run() {
	if (target != null) {
		target.run();
	}
}

1.3 有返回值的线程任务

有返回值的任务必须实现Callable接口,类似的,无返回值的任务必须实现Runnable接口。执行Callable任务后,可以获得一个Future接口,调用Future的get方法就可以获取到Callable任务返回的Object了,再结合线程池接口ExecutorService就可以实现传说中有返回结果的多线程了。

线程任务:

import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.concurrent.Callable;

public class MyCallableImpl implements Callable<Object> {

	@Override
	public Object call() throws Exception {
		Date date = new Date();
		SimpleDateFormat sdf = new SimpleDateFormat("HH:mm:ss.SSS");
		return sdf.format(date);  // 返回当前时间
	}
}

测试方法:

@Test
public void testThreadPool() throws ExecutionException, InterruptedException {
	int taskNum = 5;
	// 创建线程池
	ExecutorService pool = Executors.newFixedThreadPool(taskNum);

	// 执行任务并获取Future结果
	List<Future> resList = new ArrayList<Future>();
	for (int i = 0; i < taskNum; i++) {
		Callable c = new MyCallableImpl();
		Future f = pool.submit(c);
		resList.add(f);
	}

	// 关闭线程池
	pool.shutdown();

	// 获取所有并发任务的执行结果
	for (Future<String> res : resList) {
		System.out.println(res.get());
	}
}

结果:
image.png(没错,已经十二点了...)

2 线程池

2.1 通过线程池创建并运行线程

线程和数据库连接这些资源都是非常宝贵的资源。那么每次需要的时候创建,不需要的时候销毁,是非常浪费资源的。那么我们就可以使用缓存的策略,也就是使用线程池来提高线程的使用效率。

例子:该线程池最多运行10个线程,10个线程执行完会睡3秒然后结束运行与此同时新一批10个线程又会被启动,一直循环。

@Test
public void testThreadPool() {

	// 通过ExecutorService创建线程池
	int taskSize = 10;
	ExecutorService threadPool = Executors.newFixedThreadPool(taskSize);
	while(true) {
		threadPool.execute(new Runnable() {  // 以匿名类的方式提交多个线程任务,并执行
			@Override
			public void run() {
				System.out.println(Thread.currentThread().getName() + " is running ..");
				try {
					Thread.sleep(3000);  // 睡3秒
				} catch (InterruptedException e) {
					System.err.println("睡眠被打断");
				}
			}
		});
	}
}

2.2 四种线程池

Java里面线程池的顶级接口是Executor,但是严格意义上讲Executor并不是一个线程池,而只是一个执行线程的工具。真正的线程池接口是ExecutorService。

展示并发包部分接口和类之间的关系:
并发包部分接口和类关系

2.2.1 newCachedThreadPool

创建一个可根据需要创建新线程的线程池,但是在以前构造的线程可用时将重用它们。对于执行很多短期异步任务的程序而言,这些线程池通常可提高程序性能。调用execute将重用以前构造的线程(如果线程可用)。如果现有线程没有可用的,则创建一个新线程并添加到池中。终止并从缓存中移除那些已有60秒钟未被使用的线程。因此,长时间保持空闲的线程池不会使用任何资源。

2.2.2 newFixedThreadPool

创建一个可重用固定线程数的线程池,以共享的无界队列方式来运行这些线程。在任意点,在大多数nThreads线程会处于处理任务的活动状态。如果在所有线程处于活动状态时提交附加任务,则在有可用线程之前,附加任务将在队列中等待。如果在关闭前的执行期间由于失败而导致任何线程终止,那么一个新线程将代替它执行后续的任务(如果需要)。在某个线程被显式地关闭之前,池中的线程将一直存在。

2.2.3 newScheduledThreadPool

newScheduledThreadPool线程池是一个可以定时和延迟执行任务的线程池。

例子:创建一个线程池,它安排在给定延迟后运行命令或者定期地执行。

import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.concurrent.*;

public class TestNewScheduledThreadPool {

    public static void main(String[] args) throws ExecutionException, InterruptedException {
        TestNewScheduledThreadPool nstp = new TestNewScheduledThreadPool();
        nstp.testNewScheduledThreadPool();
    }

    public void testNewScheduledThreadPool() throws ExecutionException, InterruptedException {
        SimpleDateFormat sdf = new SimpleDateFormat("HH:mm:ss.SSS");
        System.out.println("程序开始执行 " + sdf.format(new Date()));

        // 创建scheduled线程池
        ScheduledExecutorService scheduledThreadPool= Executors.newScheduledThreadPool(3);

        // 设定延迟三秒执行
        ScheduledFuture f1 = scheduledThreadPool.schedule(new Runnable(){
            @Override
            public void run() {
                System.out.println("延迟3秒 " + sdf.format(new Date()));
            }
        }, 3, TimeUnit.SECONDS);

        // 设定延迟一秒执行,后每三秒执行一次
        ScheduledFuture f2 = scheduledThreadPool.scheduleAtFixedRate(new Runnable(){
            @Override
            public void run() {
                System.out.println("延迟1秒后每3秒执行一次 " + sdf.format(new Date()));
            }
        }, 1, 3, TimeUnit.SECONDS);
    }
}

程序执行结果:
image.png

2.2.4 newSingleThreadExecutor

Executors.newSingleThreadExecutor()返回一个线程池(这个线程池只有一个线程),这个线程池可以在线程死后(或发生异常时)重新启动一个线程来替代原来的线程继续执行下去。

3 线程的生命周期

当线程被创建并启动以后,它既不是一启动就进入了执行状态,也不是一直处于执行状态。在线程的生命周期中,它要经过新建(New)、就绪(Runnable)、运行(Running)、阻塞(Blocked)和死亡(Dead)五种状态。尤其是当线程启动以后,它不可能一直“霸占”着CPU独自运行,所以CPU需要在多条线程之间切换,于是线程状态也会多次在运行、阻塞之间切换。

线程的生命周期

3.1 线程的五种状态

3.1.1 新建状态(New)

当程序使用new关键字创建了一个线程之后,该线程就处于新建状态,此时仅由 JVM 为其分配内存,并初始化其成员变量的值。

3.1.2 就绪状态(Runnable)

当线程对象调用了start()方法之后,该线程处于就绪状态。Java虚拟机会为其创建方法调用栈和程序计数器,等待调度运行。

3.1.3 运行状态(Running)

如果处于就绪状态的线程获得了CPU,开始执行run()方法的线程执行体,则该线程处于运行状态。

3.1.4 阻塞状态(Blocked)

阻塞状态是指线程因为某种原因放弃了cpu使用权,也即让出了cpu timeslice,暂时停止运行。直到线程进入可运行(runnable)状态,才有机会再次获得cpu timeslice转到运行(running)状态。

阻塞的三种情况:

  1. 等待阻塞(o.wait -> 等待对列)
    运行(running)的线程执行o.wait()方法,JVM会把该线程放入等待队列(waitting queue)中。

  2. 同步阻塞 (lock -> 锁池)
    运行(running)的线程在获取对象的同步锁时,若该同步锁被别的线程占用,则JVM会把该线程放入锁池(lock pool)中。

  3. 其他阻塞 (sleep/join)
    运行(running)的线程执行Thread.sleep(long ms)或t.join()方法,或者发出了I/O请求时,JVM会把该线程置为阻塞状态。当sleep()状态超时、join()等待线程终止或者超时、或者 I/O处理完毕时,线程重新转入可运行(runnable)状态。

3.1.5 线程死亡(Dead)

线程会以下面三种方式结束,结束后就是死亡状态。

  1. 正常结束
    run()或call()方法执行完成,线程正常结束。
  2. 异常结束
    线程抛出一个未捕获的Exception或Error。
  3. 调用stop方法
    直接调用该线程的stop()方法来结束该线程,该方法通常容易导致死锁,不推荐使用。

3.2 终止线程的方法

3.2.1 正常退出

run方法执行完毕正常退出。

3.2.2 使用退出标志退出线程

一般run()方法执行完,线程就会正常结束,然而,常常有些线程是伺服线程。它们需要长时间的运行,只有在外部某些条件满足的情况下,才能关闭这些线程。使用一个变量来控制循环,例如:最直接的方法就是设一个boolean类型的标志,并通过设置这个标志为true或false来控制while循环是否退出。

public class ThreadSafe extends Thread {
    public volatile boolean exit = false;
    public void run() {
        while (!exit){
            //do something
        }
    }
}

定义了一个退出标志exit,当exit为true时,while循环退出,exit的默认值为false。在定义exit时,使用了一个Java关键字volatile,这个关键字的目的是使exit同步,也就是说在同一时刻只能由一个线程来修改exit的值。

3.2.3 Interrupt方法结束线程

使用interrupt()方法来中断线程有两种情况:

  1. 线程处于阻塞状态:如使用了sleep,同步锁的wait,socket中的receiver,accept等方法时,会使线程处于阻塞状态。当调用线程的interrupt()方法时,会抛出InterruptException异常。阻塞中的那个方法抛出这个异常,通过代码捕获该异常,然后break跳出循环状态,从而让我们有机会结束这个线程的执行。通常很多人认为只要调用interrupt方法线程就会结束,实际上是错的,一定要先捕获InterruptedException异常之后通过break来跳出循环,才能正常结束run方法。

  2. 线程未处于阻塞状态:使用isInterrupted()判断线程的中断标志来退出循环。当使用interrupt()方法时,中断标志就会置true,和使用自定义的标志来控制循环是一样的道理。

我的线程类

import java.text.SimpleDateFormat;
import java.util.Date;

public class MyThread extends Thread {

    SimpleDateFormat sdf = new SimpleDateFormat("HH:mm:ss.SSS");

    @Override
    public void run() {
        while (!isInterrupted()){  // 非阻塞过程中通过判断中断标志来退出
            try{
                System.out.println("开始睡觉~ " + sdf.format(new Date()));
                Thread.sleep(5 * 1000);  // 阻塞过程捕获中断异常来退出
                System.out.println("我睡醒啦~ " + sdf.format(new Date()) + "\n");
            } catch (InterruptedException e){
                System.out.println(e.getMessage() + " " + sdf.format(new Date()));
                break;  // 捕获到异常之后,执行break跳出循环
            }
        }
    }
}

测试方法

@Test
public void testInterruptThread() throws InterruptedException {
    MyThread mt = new MyThread();  // 新建线程
    mt.start();  // 就绪
    Thread.sleep(9 * 1000);  // 9秒后打断线程
    mt.interrupt();  // 打断线程
}

运行结果
image.png

3.2.4 stop方法终止线程(线程不安全)

程序中可以直接使用thread.stop()来强行终止线程,但是stop方法是很危险的,就象突然关闭计算机电源,而不是按正常程序关机一样,可能会产生不可预料的结果,不安全主要是:thread.stop()调用之后,创建子线程的线程就会抛出ThreadDeath的错误,并且会释放子线程所持有的所有锁。一般任何进行加锁的代码块,都是为了保护数据的一致性,如果在调用thread.stop()后导致了该线程所持有的所有锁的突然释放(不可控制),那么被保护数据就有可能呈现不一致性,其他线程在使用这些被破坏的数据时,有可能导致一些很奇怪的应用程序错误。因此,并不推荐使用stop方法来终止线程。

4 关于线程的一些方法

4.1 线程的基本方法

线程相关的基本方法有Object.wait(),Object.wait(long),Object.notify(),Object.notifyAll(),Thread.sleep(long),Thread.join(long),Thread.yield()等。

4.1.1 线程等待(wait)

调用该方法的线程进入WAITING状态,只有等待另外线程的通知或被中断才会返回,需要注意的是调用wait()方法后,会释放对象的锁。因此,wait方法一般用在同步方法或同步代码块中。

4.1.2 线程睡眠(sleep)

sleep导致当前线程休眠,与wait方法不同的是sleep不会释放当前占有的锁,sleep(long)会导致线程进入TIMED-WATING状态,而wait()方法会导致当前线程进入WATING状态。

4.1.3 线程让步(yield)

yield会使当前线程让出CPU执行时间片,与其他线程一起重新竞争CPU时间片。一般情况下,优先级高的线程有更大的可能性成功竞争得到CPU时间片,但这又不是绝对的,有的操作系统对线程优先级并不敏感。

4.1.4 线程中断(interrupt)

中断一个线程,其本意是给这个线程一个通知信号,会影响这个线程内部的一个中断标识位。这个线程本身并不会因此而改变状态(如阻塞,终止等)。

  1. 调用interrupt()方法并不会中断一个正在运行的线程。也就是说处于Running状态的线程并不会因为被中断而被终止,仅仅改变了内部维护的中断标识位而已。
  2. 若调用sleep()而使线程处于TIMED-WATING状态,这时调用interrupt()方法,会抛出InterruptedException,从而使线程提前结束TIMED-WATING状态。
  3. 许多声明抛出InterruptedException的方法(如Thread.sleep(long mills)方法),抛出异常前,都会清除中断标识位,所以抛出异常后,调用 isInterrupted()方法将会返回false。
  4. 中断状态是线程固有的一个标识位,可以通过此标识位安全的终止线程。比如,你想终止一个线程thread的时候,可以调用thread.interrupt()方法,在线程的run方法内部可以根据thread.isInterrupted()的值来优雅的终止线程。

4.1.5 等待其他线程结束(join)

join()方法,等待其他线程终止,在当前线程中调用另一个线程的join()方法,则当前线程转为阻塞状态,等到另一个线程结束,当前线程再由阻塞状态变为就绪状态,等待CPU的恩赐。

很多情况下,主线程生成并启动了子线程,需要用到子线程返回的结果,也就是需要主线程需要在子线程结束后再结束,这时候就要用到join()方法。

4.1.6 线程唤醒(notify)

Object类中的notify()方法,唤醒在此对象监视器上等待的单个线程,如果所有线程都在此对象上等待,则会选择唤醒其中一个线程,选择是任意的,并在对实现做出决定时发生,线程通过调用其中一个wait()方法,在对象的监视器上等待,直到当前的线程放弃此对象上的锁定,才能继续执行被唤醒的线程,被唤醒的线程将以常规方式与在该对象上主动同步的其他所有线程进行竞争。类似的方法还有notifyAll(),唤醒在此监视器上等待的所有线程。

4.2 sleep与wait的区别

  1. 对于sleep()方法,我们首先要知道该方法是属于Thread类中的。而wait()方法,则是属于Object类中的。
  2. sleep()方法导致了程序暂停执行指定的时间,让出CPU给其他线程,但是他的监控状态依然保持着,当指定的时间到了又会自动恢复运行状态。
  3. 在调用sleep()方法的过程中,线程不会释放对象锁。
  4. 而当调用wait()方法的时候,线程会放弃对象锁,进入等待此对象的等待锁定池,只有针对此对象调用notify()方法后本线程才进入对象锁定池准备获取对象锁进入运行状态。

4.3 start与run区别

  1. start()方法来启动线程,真正实现了多线程运行。这时无需等待run方法体代码执行完毕,可以直接继续执行下面的代码。
  2. 通过调用Thread类的start()方法来启动一个线程,这时此线程是处于就绪状态,并没有运行。
  3. 方法run()称为线程体,它包含了要执行的这个线程的内容,线程就进入了运行状态,开始运行run方法当中的代码。run方法运行结束,此线程终止。然后CPU再调度其它线程。

4.4 方法汇总

  1. sleep():强迫一个线程睡眠N毫秒。
  2. isAlive():判断一个线程是否存活。
  3. join():等待线程终止。
  4. activeCount():程序中活跃的线程数。
  5. enumerate():枚举程序中的线程。
  6. currentThread():得到当前线程。
  7. isDaemon():一个线程是否为守护线程。
  8. setDaemon():设置一个线程为守护线程。(用户线程和守护线程的区别在于,是否等待主线程依赖于主线程结束而结束)
  9. setName():为线程设置一个名称。
  10. wait():强迫一个线程等待。
  11. notify():通知一个线程继续运行。
  12. setPriority():设置一个线程的优先级。
  13. getPriority():获得一个线程的优先级。

5 守护线程(Java后台线程)

5.1 定义

守护线程也称“服务线程”,他是后台线程,它有一个特性,即为用户线程提供公
共服务,在没有用户线程可服务时会自动离开。

5.2 优先级

守护线程的优先级比较低,用于为系统中的其它对象和线程提供服务。

5.3 设置

通过setDaemon(true)来设置线程为“守护线程”。
将一个用户线程设置为守护线程的方式是在线程对象创建之前调用线程对象的 setDaemon方法。

5.4 特点

在Daemon线程中产生的新线程也是Daemon的。

线程则是JVM级别的,以Tomcat为例,如果你在Web应用中启动一个线程,这个线程的生命周期并不会和Web应用程序保持同步。也就是说,即使你停止了 Web 应用,这个线程依旧是活跃的。

举例:垃圾回收线程就是一个经典的守护线程,当我们的程序中不再有任何运行的Thread,程序就不会再产生垃圾,垃圾回收器也就无事可做,所以当垃圾回收线程是JVM上仅剩的线程时,垃圾回收线程会自动离开。它始终在低级别的状态中运行,用于实时监控和管理系统中的可回收资源。

5.5 生命周期

守护进程(Daemon)是运行在后台的一种特殊进程。它独立于控制终端并且周期性地执行某种任务或等待处理某些发生的事件。也就是说守护线程不依赖于终端,但是依赖于系统,与系统“同生共死”。当JVM中所有的线程都是守护线程的时候,JVM就可以退出了;如果还有一个或以上的非守护线程则JVM不会退出。

6 Java的锁

6.1 乐观锁

乐观锁是一种乐观思想,即认为读多写少,遇到并发写的可能性低,每次去拿数据的时候都认为别人不会修改,所以不会上锁,但是在更新的时候会判断一下在此期间别人有没有去更新这个数据,采取在写时先读出当前版本号,然后加锁操作(比较跟上一次的版本号,如果一样则更新),如果失败则要重复读-比较-写的操作。

Java中的乐观锁基本都是通过CAS操作实现的,CAS 是一种更新的原子操作,比较当前值跟传入值是否一样,一样则更新,否则失败。

6.2 悲观锁

悲观锁是就是悲观思想,即认为写多,遇到并发写的可能性高,每次去拿数据的时候都认为别人会修改,所以每次在读写数据的时候都会上锁,这样别人想读写这个数据就会block直到拿到锁。Java中的悲观锁就是Synchronized,AQS框架下的锁则是先尝试cas乐观锁去获取锁,获取不到,才会转换为悲观锁,如RetreenLock。

6.3 自旋锁

自旋锁原理非常简单,如果持有锁的线程能在很短时间内释放锁资源,那么那些等待竞争锁的线程就不需要做内核态和用户态之间的切换进入阻塞挂起状态,它们只需要等一等(即自旋),等持有锁的线程释放锁后即可立即获取锁,这样就避免用户线程和内核的切换的消耗。

线程自旋是需要消耗CUP的,说白了就是让CUP在做无用功,如果一直获取不到锁,那线程也不能一直占用CUP自旋做无用功,所以需要设定一个自旋等待的最大时间。

如果持有锁的线程执行的时间超过自旋等待的最大时间扔没有释放锁,就会导致其它争用锁的线程在最大等待时间内还是获取不到锁,这时争用线程会停止自旋进入阻塞状态。

6.3.1 自旋锁的优缺点

自旋锁尽可能的减少线程的阻塞,这对于锁的竞争不激烈且占用锁时间非常短的代码块来说性能能大幅度的提升,因为自旋的消耗会小于线程阻塞挂起再唤醒的操作的消耗,这些操作会导致线程发生两次上下文切换!

但是如果锁的竞争激烈,或者持有锁的线程需要长时间占用锁执行同步块,这时候就不适合使用自旋锁了,因为自旋锁在获取锁前一直都是占用CPU做无用功,占着茅坑不拉屎,同时有大量线程在竞争一个锁,会导致获取锁的时间很长,线程自旋的消耗大于线程阻塞挂起操作的消耗,其它需要CPU的线程又不能获取到CPU,造成CPU的浪费。所以这种情况下我们要关闭自旋锁。

6.3.2 自旋锁的阈值

自旋锁的目的是为了占着CPU的资源不释放,等到获取到锁立即进行处理。但是如何去选择自旋的执行时间呢?如果自旋执行时间太长,会有大量的线程处于自旋状态占用CPU资源,进而会影响整体系统的性能。因此自旋的周期选的额外重要!

JVM对于自旋周期的选择,jdk1.5这个限度是一定的写死的,在1.6引入了适应性自旋锁,适应性自旋锁意味着自旋的时间不在是固定的了,而是由前一次在同一个锁上的自旋时间以及锁的拥有者的状态来决定,基本认为一个线程上下文切换的时间是最佳的一个时间,同时JVM还针对当前CPU的负荷情况做了较多的优化,如果平均负载小于CPUs则一直自旋,如果有超过(CPUs/2)个线程正在自旋,则后来线程直接阻塞,如果正在自旋的线程发现Owner发生了变化则延迟自旋时间(自旋计数)或进入阻塞,如果CPU处于节电模式则停止自旋,自旋时间的最坏情况是CPU的存储延迟(CPU A存储了一个数据,到CPU B得知这个数据直接的时间差),自旋时会适当放弃线程优先级之间的差异。

6.3.3 自旋锁的开启

JDK1.6 中-XX:+UseSpinning开启;

-XX:PreBlockSpin=10为自旋次数;

JDK1.7后,去掉此参数,由jvm控制;

6.4 Synchronized同步锁

synchronized它可以把任意一个非NULL的对象当作锁。他属于独占式的悲观锁,同时属于可重入锁。

6.4.1 Synchronized作用范围

  1. 作用于方法时,锁住的是对象的实例(this)。
  2. 当作用于静态方法时,锁住的是Class实例,又因为Class的相关数据存储在永久带PermGen(jdk1.8则是metaspace),永久带是全局共享的,因此静态方法锁相当于类的一个全局锁,会锁所有调用该方法的线程。
  3. synchronized作用于一个对象实例时,锁住的是所有以该对象为锁的代码块。它有多个队列,当多个线程一起访问某个对象监视器的时候,对象监视器会将这些线程存储在不同的容器中。

6.4.2 Synchronized核心组件

  1. Wait Set:那些调用wait方法被阻塞的线程被放置在这里。
  2. Contention List:竞争队列,所有请求锁的线程首先被放在这个竞争队列中。
  3. Entry List:Contention List 中那些有资格成为候选资源的线程被移动到 Entry List中。
  4. OnDeck:任意时刻,最多只有一个线程正在竞争锁资源,该线程被成为OnDeck
  5. Owner:当前已经获取到资源的线程被称为Owner。
  6. !Owner:当前释放锁的线程。

6.4.3 Synchronized实现

Synchronized原理图

  1. JVM每次从队列的尾部取出一个数据用于锁竞争候选者(OnDeck),但是并发情况下,ContentionList会被大量的并发线程进行CAS访问,为了降低对尾部元素的竞争,JVM会将一部分线程移动到EntryList中作为候选竞争线程。

  2. Owner线程会在unlock时,将ContentionList中的部分线程迁移到EntryList中,并指定EntryList中的某个线程为OnDeck线程(一般是最先进去的那个线程)。

  3. Owner线程并不直接把锁传递给OnDeck线程,而是把锁竞争的权利交给OnDeck,OnDeck需要重新竞争锁。这样虽然牺牲了一些公平性,但是能极大的提升系统的吞吐量,在JVM中,也把这种选择行为称之为“竞争切换”。

  4. OnDeck线程获取到锁资源后会变为Owner线程,而没有得到锁资源的仍然停留在EntryList中。如果Owner线程被wait方法阻塞,则转移到WaitSet队列中,直到某个时刻通过notify或者notifyAll唤醒,会重新进去EntryList中。

  5. 处于ContentionList、EntryList、WaitSet中的线程都处于阻塞状态,该阻塞是由操作系统来完成的(Linux内核下采用pthread_mutex_lock内核函数实现的)。

  6. Synchronized是非公平锁。Synchronized在线程进入ContentionList时,等待的线程会先尝试自旋获取锁,如果获取不到就进入ContentionList,这明显对于已经进入队列的线程是不公平的,还有一个不公平的事情就是自旋获取锁的线程还可能直接抢占OnDeck线程的锁资源。

  7. 每个对象都有个monitor对象,加锁就是在竞争monitor对象,代码块加锁是在前后分别加上monitorenter和monitorexit指令来实现的,方法加锁是通过一个标记位来判断的。

  8. synchronized是一个重量级操作,需要调用操作系统相关接口,性能是低效的,有可能给线程加锁消耗的时间比有用操作消耗的时间更多。

  9. Java1.6,synchronized进行了很多的优化,有适应自旋、锁消除、锁粗化、轻量级锁及偏向锁等,效率有了本质上的提高。在之后推出的Java1.7与1.8中,均对该关键字的实现机理做了优化。引入了偏向锁和轻量级锁。都是在对象头中有标记位,不需要经过操作系统加锁。

  10. 锁可以从偏向锁升级到轻量级锁,再升级到重量级锁。这种升级过程叫做锁膨胀。

  11. JDK1.6中默认是开启偏向锁和轻量级锁,可以通过-XX:-UseBiasedLocking来禁用偏向锁。

6.5 ReentrantLock(狭义可重入锁)

java.util.concurrent.locks.ReentantLock继承接口java.util.concurrent.locks.Lock并实现了接口中定义的方法,他是一种可重入锁,除了能完成synchronized所能完成的所有工作外,还提供了诸如可响应中断锁、可轮询锁请求、定时锁等避免多线程死锁的方法。

6.5.1 Lock接口的主要方法

  1. void lock():执行此方法时,如果锁处于空闲状态,当前线程将获取到锁。相反,如果锁已经被其他线程持有,将禁用当前线程,直到当前线程获取到锁。

  2. boolean tryLock():如果锁可用,则获取锁,并立即返回true,否则返回false。该方法和lock()的区别在于,tryLock()只是“试图”获取锁,如果锁不可用,不会导致当前线程被禁用,当前线程仍然继续往下执行代码。而lock()方法则是一定要获取到锁,如果锁不可用,就一直等待,在未获得锁之前,当前线程并不继续向下执行。

  3. boolean tryLock(long timeout, TimeUnit unit):如果锁在给定等待时间内没有被另一个线程保持,则获取该锁。

  4. void unlock():执行此方法时,当前线程将释放持有的锁。锁只能由持有者释放,如果线程并不持有锁,却执行该方法,可能导致异常的发生。

  5. Condition newCondition():条件对象,获取等待通知组件。该组件和当前的锁绑定,当前线程只有获取了锁,才能调用该组件的await()方法,而调用后,当前线程将释放锁。

  6. int getHoldCount():查询当前线程保持此锁的次数,也就是此线程执行lock方法的次数。

  7. int getQueueLength():返回正等待获取此锁的线程估计数,比如启动10个线程,1个线程获得锁,此时返回的是9。

  8. int getWaitQueueLength(Condition condition):返回等待与此锁相关的给定条件的线程估计数。比如10个线程,用同一个condition对象,并且此时这10个线程都执行了condition对象的await方法,那么此时执行此方法返回10。

  9. boolean hasWaiters(Condition condition):查询是否有线程等待与此锁有关的给定条件(condition),对于指定contidion对象,有多少线程执行了condition.await()方法。

  10. boolean hasQueuedThread(Thread thread):查询给定线程是否等待获取此锁。

  11. boolean hasQueuedThreads():是否有线程等待此锁。

  12. boolean isFair():该锁是否公平锁。

  13. boolean isHeldByCurrentThread():查询当前线程是否保持锁,线程的执行lock方法的前后分别是false和true。

  14. boolean isLock():此锁是否有任意线程占用。

  15. void lockInterruptibly():如果当前线程未被中断,获取锁。

6.5.2 ReentrantLock非公平锁

JVM按随机、就近原则分配锁的机制则称为不公平锁,ReentrantLock在构造函数中提供了是否公平锁的初始化方式,默认为非公平锁。非公平锁实际执行的效率要远远超出公平锁,除非程序有特殊需要,否则最常用非公平锁的分配机制。

6.5.3 ReentrantLock公平锁

公平锁指的是锁的分配机制是公平的,通常先对锁提出获取请求的线程会先被分配到锁,ReentrantLock在构造函数中提供了是否公平锁的初始化方式来定义公平锁。

6.5.4 ReentrantLock实现

import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

public class MyReentrantLockTest {
    // 默认非公平锁
    private Lock lock = new ReentrantLock();

    // 公平锁
    //private Lock lock = new ReentrantLock(true);

    // 非公平锁
    //private Lock lock = new ReentrantLock(false);

    // 创建Condition
    private Condition condition = lock.newCondition();

    public void testLockMethod() {
        try {
            // 加锁
            lock.lock();

            // 1:wait 方法等待
            System.out.println("调用await()方法");
            // 通过创建Condition对象来使线程wait,必须先执行lock.lock方法获得锁
            condition.await();

            // 2:signal方法唤醒
            condition.signal();  // condition对象的signal方法可以唤醒wait线程

            for (int i = 0; i < 5; i++) {
                System.out.println("ThreadName=" + Thread.currentThread().getName() + (" " + (i + 1)));
            }
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        finally{
            // 释放锁
            lock.unlock();
        }
    }
}

6.5.5 Condition类和Object类锁方法的区别

  1. Condition类的awiat方法和Object类的wait方法等效。
  2. Condition类的signal方法和Object类的notify方法等效。
  3. Condition类的signalAll方法和Object类的notifyAll方法等效。
  4. ReentrantLock类可以唤醒指定条件的线程,而Object的唤醒是随机的。

6.5.6 tryLock、lock和lockInterruptibly的区别

  1. tryLock能获得锁就返回true,不能就立即返回false,tryLock(long timeout, TimeUnit unit),可以增加时间限制,如果超过该时间段还没获得锁,返回false。
  2. lock能获得锁就立刻得到锁,不能的话一直等待获得锁。
  3. lock和lockInterruptibly,如果两个线程分别执行这两个方法,但此时中断这两个线程,lock不会抛出异常,而lockInterruptibly会抛出异常。

6.5.7 ReentrantLock与synchronized

  1. ReentrantLock通过方法lock()与unlock()来进行加锁与解锁操作,与synchronized会被JVM自动解锁机制不同,ReentrantLock加锁后需要手动进行解锁。为了避免程序出现异常而无法正常解锁的情况,使用ReentrantLock必须在finally控制块中进行解锁操作。
  2. ReentrantLock相比synchronized的优势是可中断、公平锁、多个锁。这种情况下需要使用ReentrantLock。

6.6 Semaphore信号量

Semaphore是一种基于计数的信号量。它可以设定一个阈值,基于此,多个线程竞争获取许可信号,做完自己的申请后归还,超过阈值后,线程申请许可信号将会被阻塞。Semaphore可以用来构建一些对象池,资源池之类的,比如数据库连接池。

6.6.1 基础用法

@Test
public void testSemaphore() {
	
	// 创建一个计数阈值为5的信号量对象 
	// 只能5个线程同时访问 
	Semaphore semp = new Semaphore(5); 
	try { // 申请许可 
		semp.acquire(); 
		try { 
			// 业务逻辑...
		} catch (Exception e) { 
			
		} finally { 
			// 释放许可 
			semp.release(); 
		} 
	} catch (InterruptedException e) {
		
	}
	
}

6.6.2 可用作实现互斥锁(信号量1)

我们也可以创建计数为1的Semaphore,将其作为一种类似互斥锁的机制,这也叫二元信号量,表示两种互斥状态。

6.6.3 Semaphore与ReentrantLock

Semaphore基本能完成ReentrantLock的所有工作,使用方法也与之类似,通过acquire()与release()方法来获得和释放临界资源。经实测,Semaphone.acquire()方法默认为可响应中断锁,与ReentrantLock.lockInterruptibly()作用效果一致,也就是说在等待临界资源的过程中可以被Thread.interrupt()方法中断。

此外,Semaphore也实现了可轮询的锁请求与定时锁的功能,除了方法名 tryAcquire与tryLock不同,其使用方法与ReentrantLock几乎一致。Semaphore也提供了公平与非公平锁的机制,也可在构造函数中进行设定。

Semaphore的锁释放操作也由手动进行,因此与ReentrantLock一样,为避免线程因抛出异常而无法正常释放锁的情况发生,释放锁的操作也必须在finally代码块中完成。

6.7 原子的基础类型包装类

AtomicInteger,一个提供原子操作的Integer的类,常见的还有AtomicBoolean、AtomicInteger、AtomicLong、AtomicReference等,他们的实现原理相同,区别在与运算对象类型的不同。令人兴奋地,还可以通过AtomicReference将一个对象的所有操作转化成原子操作。

我们知道,在多线程程序中,诸如i或i等运算不具有原子性,是不安全的线程操作之一。通常我们会使用synchronized将该操作变成一个原子操作,但 JVM为此类操作特意提供了一些同步类,使得使用更方便,且使程序运行效率变得更高。通过相关资料显示,通常AtomicInteger的性能是ReentantLock的好几倍。

6.8 广义的可重入锁(递归锁)

该段讲的是广义上的可重入锁,而不是单指Java中的ReentrantLock。
可重入锁,也叫做递归锁,指的是同一线程外层函数获得锁之后,内层递归函数仍然有获取该锁的代码,但不受影响。在Java环境下ReentrantLock和synchronized都是可重入锁。

6.9 公平锁与非公平锁(广义)

6.9.1 公平锁(Fair)

加锁前检查是否有排队等待的线程,优先排队等待的线程,先来先得。

6.9.2 非公平锁(Nonfair)

加锁时不考虑排队等待问题,直接尝试获取锁,获取不到自动到队尾等待。

  1. 非公平锁性能比公平锁高5~10倍,因为公平锁需要在多核的情况下维护一个队列
  2. Java中的synchronized是非公平锁,ReentrantLock默认的lock()方法采用的是非公平锁。

6.10 ReadWriteLock读写锁

为了提高性能,Java提供了读写锁,在读的地方使用读锁,在写的地方使用写锁,灵活控制,如果没有写锁的情况下,读是无阻塞的,在一定程度上提高了程序的执行效率。读写锁分为读锁和写锁,多个读锁不互斥,读锁与写锁互斥,这是由jvm自己控制的,你只要上好相应的锁即可。
Java中读写锁有个接口java.util.concurrent.locks.ReadWriteLock,也有具体的实现ReentrantReadWriteLock。

  1. 读锁
    如果你的代码只读数据,可以很多人同时读,但不能同时写,那就上读锁。

  2. 写锁
    如果你的代码修改数据,只能有一个人在写,且不能同时读取,那就上写锁。总之,读的时候上读锁,写的时候上写锁!

6.11 共享锁和独占锁

Java并发包提供的加锁模式分为独占锁和共享锁。

6.11.1 独占锁

独占锁模式下,每次只能有一个线程能持有锁,ReentrantLock就是以独占方式实现的互斥锁。独占锁是一种悲观保守的加锁策略,它避免了读/读冲突,如果某个只读线程获取锁,则其他读线程都只能等待,这种情况下就限制了不必要的并发性,因为读操作并不会影响数据的一致性。

6.11.2 共享锁

共享锁则允许多个线程同时获取锁,并发访问共享资源如:ReadWriteLock。共享锁则是一种乐观锁,它放宽了加锁策略,允许多个执行读操作的线程同时访问共享资源。
AQS的内部类Node定义了两个常量SHARED和EXCLUSIVE,他们分别标识AQS队列中等待线程的锁获取模式。

6.12 锁的等级(状态)

锁的状态总共有四种:无锁状态、偏向锁、轻量级锁和重量级锁。

锁升级:随着锁的竞争,锁可以从偏向锁升级到轻量级锁,再升级到重量级锁。但是锁的升级是单向的,也就是说只能从低到高升级,不会出现锁的降级。

6.12.1 重量级锁(Mutex Lock)

Synchronized是通过对象内部的一个叫做监视器锁(monitor)来实现的。但是监视器锁本质又是依赖于底层的操作系统的Mutex Lock来实现的。而操作系统实现线程之间的切换这就需要从用户态转换到核心态,这个成本非常高,状态之间的转换需要相对比较长的时间,这就是为什么Synchronized效率低的原因。因此,这种依赖于操作系统Mutex Lock所实现的锁我们称之为“重量级锁”。JDK中对Synchronized做的种种优化,其核心都是为了减少这种重量级锁的使用。JDK1.6 以后,为了减少获得锁和释放锁所带来的性能消耗,提高性能,引入了“轻量级锁”和“偏向锁”。

6.12.2 轻量级锁

“轻量级”是相对于使用操作系统互斥量来实现的传统锁而言的。但是,首先需要强调一点的是,轻量级锁并不是用来代替重量级锁的,它的本意是在没有多线程竞争的前提下,减少传统的重量级锁使用产生的性能消耗。在解释轻量级锁的执行过程之前,先明白一点,轻量级锁所适应的场景是线程交替执行同步块的情况,如果存在同一时间访问同一锁的情况,就会导致轻量级锁膨胀为重量级锁。

6.12.3 偏向锁

偏向锁的目的是在某个线程获得锁之后,消除这个线程锁重入(CAS)的开销,看起来让这个线程得到了偏护。
引入偏向锁是为了在无多线程竞争的情况下尽量减少不必要的轻量级锁执行路径,因为轻量级锁的获取及释放依赖多次CAS原子指令,而偏向锁只需要在置换ThreadID的时候依赖一次CAS原子指令(由于一旦出现多线程竞争的情况就必须撤销偏向锁,所以偏向锁的撤销操作的性能损耗必须小于节省下来的CAS原子指令的性能消耗)。
上面说过,轻量级锁是为了在线程交替执行同步块时提高性能,而偏向锁则是在只有一个线程执行同步块时进一步提高性能。

6.13 锁优化

6.13.1 减少锁持有时间

只用在有线程安全要求的程序上加锁

6.13.2 减小锁粒度

将大对象(这个对象可能会被很多线程访问),拆成小对象,大大增加并行度,降低锁竞争。降低了锁的竞争,偏向锁,轻量级锁成功率才会提高。最最典型的减小锁粒度的案例就是ConcurrentHashMap。

6.13.3 锁分离

最常见的锁分离就是读写锁ReadWriteLock,根据功能进行分离成读锁和写锁,这样读读不互斥,读写互斥,写写互斥,即保证了线程安全,又提高了性能。读写分离思想可以延伸,只要操作互不影响,锁就可以分离。比如LinkedBlockingQueue从头部取出,从尾部放数据。

6.13.4 锁粗化

通常情况下,为了保证多线程间的有效并发,会要求每个线程持有锁的时间尽量短,即在使用完公共资源后,应该立即释放锁。但是,凡事都有一个度,如果对同一个锁不停的进行请求、同步和释放,其本身也会消耗系统宝贵的资源,反而不利于性能的优化。

6.13.5 锁消除

锁消除是在编译器级别的事情。在即时编译器时,如果发现不可能被共享的对象,则可以消除这些对象的锁操作,多数是因为程序员编码不规范引起。

To Be Continue

# 程序设计 # Java
Java的反射
SpringBoot学习笔记-自动装配浅析
  • 文章目录
  • 站点概览
优律

优律

优律的知识库

67 日志
17 分类
41 标签
E-mail Twitter Instagram
Links
  • CZLisyx - 浮生志
  • Vedfolnir
0%
© 2019 — 2022 萤火小屋——优律的博客网站
网站已勉强运行 
Halo博客系统技术支持