多线程学习笔记

一、并发编程的挑战

1. 频繁的上下文切换

cpu为线程分配时间片,时间片很短(毫秒级别),cpu不停地切换线程执行,在切换前会保存上一个任务的状态以便下次切回这个任务时可以再加载。
上下文的频繁切换会带来性能开销,**如何减少上下文切换的开销?**可以通过无锁化编程、CAS算法、减少线程量和协程等方法。

无锁并发编程:多线程竞争锁引起上下文切换,避免使用锁可以优化,典型的就是ConcurrHashMap
CAS:比如Atomic包里的原子类使用CAS算法来更新数据,二不需要加锁;
减少线程量:避免创建不必要的线程;
协程:在Go语言里使用较多,Java里用的少,部分框架提供,但比较古老。

2. 死锁
3. 线程安全问题
4. 资源限制
  1. 硬件资源
    1.1. 带宽的上传/下载速度;
    1.2. 硬盘读写速度;
    1.3. cpu处理速度;
  2. 软件资源
    2.1. 数据库连接限制:数据库500个连接,但有1000个线程查询,速度并不会加快;
    2.2. socket的最大打开数。

二、线程基础

1. 进程与线程的区别
1.1. 从大小角度

进程:系统进行分配和管理资源的基本单位;
线程:进程的一个执行单元,是进程内调度的实体,是cpu调度和分派的基本单位,是比进程更小的独立运行的基本单位。线程也被称为轻量级进程,是程序执行的最小单位。

一个程序至少有一个进程,一个进程至少有一个线程。

1.2. 从内存和数据角度

进程:有自己的独立地址空间,没启动一个进程,系统就会为他分配地址空间,建立数据表来维护代码段、堆栈段和数据段,这种操作非常昂贵;
线程:线程共享进程中的数据,使用相同的地址空间,因此cpu切换一个线程的花费远比进程小得多,同事创建一个线程的开销也比进程小得多;

1.3. 从通信角度

进程:进程间的通信需要以通信的方式进行;
线程:线程间的通信更方便,同一进程下的线程共享全局变量、静态变量等数据。

1.4. 从程序鲁棒性角度

进程:进程有独立的地址空间,一个进程崩溃后在保护模式下不会对其他进程产生影响,因此多进程的程序更加健壮;
线程:线程只是一个进程中的不同执行路径,线程有自己的堆栈和局部变量,但线程之间没有单独的地址空间,所以一个线程出现问题,可能导致整个程序出现问题。

2. 线程状态及其相互转换
2.1. 初始(NEW)

新建一个线程,但还没有调用start()方法。

2.2. 运行(RUNNABLE)

线程被调用了start()方法处于可运行的状态在JVM中执行,但它可能正在等待来自操作系统的其他资源,例如处理器。

2.3. 阻塞(BLOCKED)

线程阻塞于synchronized锁,等待获取synchronized锁的状态。

2.4. 等待(WAITING)

Object.wait()join()LockSupport.park()等,进入该状态的线程需要等待其他线程做出一些特定的动作(如唤起或者中断)。

2.5. 超时等待(TIME_WAITING)

Object.wait(long)Thread.join()LockSupport.parkNanos()LockSipport.parkUntil()等,该状态不同于等待状态,它可以在指定的时间内自行返回。

2.6. 终止(TERMINATED)

表示该线程已经执行完毕。

3. 创建线程的方式
3.1. 继承Thread类

继承Thread类,并重写父类的run()方法,然后new这个MyThread类;

    MyThread myThread = new MyThread();
3.2. 实现Runnable接口

实现Runnable接口,并实现run()方法,然后new一个Thread并且传入这个接口。

    Thread thread = new Thread(new MyRunnable());

一般在开发中选择第二种,因为Java只允许单继承,而且可以增加程序的健壮性,代码可以共享,代码跟数据独立。

3.3. 匿名内部类方式
    Thread thread = new Thread(new Runnable() {
			
			@Override
			public void run() {
				// TODO Auto-generated method stub
				
			}
		});
3.4. Lambda表达式方法
    new Thread(() -> {
			System.out.println(Thread.currentThread().getName());
		}).start();
3.5. 通过线程池
4. 线程的挂起和恢复
4.1. 什么是挂起线程?

线程的挂起操作实质上就是使线程进入“非可执行”状态下,在这个状态下cpu不会分给线程时间片。这个状态可以用来暂停一个线程的运行。在线程挂起后,可以通过重新唤醒线程来使之恢复运行。

4.2. 为什么要挂起线程?

cpu分配的时间片非常短也非常珍贵。挂起一个不必要的线程可以避免资源的浪费。

4.3. 如何挂起/唤醒一个线程?
  • 已经废弃的方法:
    • thread.suspend():该方法不会释放线程所占用的资源,如果用这个方法挂起牧歌线程,有可能使其它等待资源的线程死锁;
    • thread.resume()方法:该方法本身没有问题,但它是和thread.suspend()方法配套使用的,无法独立存在;
  • 可以使用的方法:
    • obj.wait():暂停执行,放弃已经获得的锁,进入等待状态;
    • obj.notify()随机唤醒一个正在等待的线程;
    • obj.notifyAll()唤醒所有正在等待的线程,自行抢占cpu资源。

注意obj.wait()obj.notify()obj.notifyAll()这三个方法在使用时必须持有锁,也就是说必须要在synchronized语句里运行,而且运行obj.notify()obj.notifyAll()时锁住的的对象必须和obj.wait()锁住的对象一致。

4.4. 什么时候适合挂起线程?

在等待某些未就绪的资源时,这样可以避免无用的时间浪费,直到notify方法被调用。

5. 线程的中断
  • stop()方法:废弃方法,开发中不要使用。因为一调用线程就立刻停止,此时有可能引发相应的线程安全性问题;
  • interrupt()方法:如果对一个线程调用此方法,不会立刻停止这个线程,而是会对这个线程打上一个标记,然后可以根据isInterrupt()方法判断某个线程是否被打上中断标记,并通过相应的逻辑来中断线程;
public class InterruptDemo implements Runnable{

	@Override
	public void run() {
		//利用标记来决定是否继续执行
		while(!Thread.currentThread().isInterrupted()) {
			System.out.println(Thread.currentThread().getName());
		}
	}
	
	public static void main(String[] args) throws InterruptedException {
		Thread thread = new Thread(new InterruptDemo());
		thread.start();
		Thread.sleep(1000);
		thread.interrupt();
	}

}
  • 使用volatile关键字自行定义一个标志:自定义一个flag但必须用volatile修饰
public class MyThreadDemo implements Runnable{
    //必须用volatile修饰
	private static volatile boolean flag = true;
	
	@Override
	public void run() {
		// TODO Auto-generated method stub
		while(flag) {
			System.out.println(Thread.currentThread().getName());
		}
	}

	public static void main(String[] args) throws InterruptedException {
		Thread thread = new Thread(new MyThreadDemo());
		thread.start();
		Thread.sleep(1000);
		flag = false;
	}
}
6. 线程的优先级
  • 线程的优先级表示的是该线程的重要程度。如果大量线程被堵塞,都在等候运行,程序会尽可能先运行优先级高的线程。但这并不是绝对的,优先级只表示该线程被准许运行的可能性大小
  • 线程优先级可以设置为1 ~ 10的任意数值,Thread类中定义了三个线程优先级,分别是MIN_PRIORITY(1)NORM_PRIORITY(5)MAX_PRIORITY(10)。一般情况下推荐使用这三个常量,不要自行设置;
  • 不同平台对优先级的支持不同。在编程从不要过度依赖优先级,如果你的程序是否正确取决于你设置的优先级,那这样的程序是不正确的;
  • 优先级是cpu分配时间片的概率,如果你的任务槽里有需要快速处理的线程任务,可以为它设置高优先级;若果有慢慢处理的线程任务,可以为它设置低优先级。
7. 守护线程
  1. 线程分类
    • 用户线程:只要有一个用户线程没运行完,程序就不会退出;
    • 守护线程:任何一个守护线程都是整个程序组所有线程的守护者,只要有活着的用户线程,守护线程就活着。当JVM实例中最后一个守护线程结束时,也随着JVM一起退出。
  2. 守护线程的用处:一般吧JVM的垃圾清理线程放在守护线程里;
  3. 建议:尽量少使用守护线程,因为其不可控;不要再守护线程里进行读写操作和执行计算逻辑。
  4. 如何设置守护线程:调用setDaemon(true)方法,相当于一个开关,默认是关闭false,设置成true就是打开。setDaemon()方法一定要在start()方法前调用,即要在线程启动前设置守护线程
public class DaemonThreadDemo implements Runnable{

	@Override
	public void run() {
		// TODO Auto-generated method stub
		while(true) {
			System.out.println(Thread.currentThread().getName());
		}
	}
	
	public static void main(String[] args) throws InterruptedException {
		Thread thread = new Thread(new DaemonThreadDemo());
		//守护线程开关一般默认关闭false
		thread.setDaemon(true);
		thread.start();
		Thread.sleep(2000);
	}
	
}

三、线程安全性

1. 原子性操作
1.1. 什么是原子性操作

一个操作或者多个操作,要么全部执行且执行过程不会被任何因素大白,要么就不执行(要么一起成功,要么一起失败)。

1.2. 如何把非原子性操作变成原子性操作

使用synchronized关键字或者锁等方法。

注意:volatile关键字是让变量具有可见性,不保证原子性。

2. synchronized关键字
2.1 内置锁和互斥锁
  • 内置锁:每个Java对象都可以作为一个实现同步的锁,这就叫内置锁;
    每个Java对象的对象头都有个锁的标志位,通过这个标志位来判断哪个线程可以获得这个相应的锁,从而判定哪个线程可以进入这个同步代码块或者同步方法里线程在进入同步代码块或者同步方法时或自动获得锁,在退出时再释放锁;
    获得内置锁的唯一途径就是进入这个锁的保护的同步代码块或者方法。
  • 互斥锁:内置锁是一个互斥锁,这就意味着同一时刻最多只有一个线程能够获得该锁;
    当线程A尝试去获得线程B持有的内置锁时,线程A必须等待或者阻塞,直到线程B释放这个锁;
    如果线程B不释放这个锁,线程A讲永远等待下去。
2.2 synchronized可修饰哪些地方?
  • 修饰普通方法:锁住的是这个对象的实例;
public class SynDemo {
	
	public synchronized void out() throws InterruptedException {
		System.out.println(Thread.currentThread().getName());
		Thread.sleep(5000);		
	}
	
	
	public static void main(String[] args) {
		SynDemo synDemo = new SynDemo();
		SynDemo synDemo2 = new SynDemo();
		
		new Thread(() -> {
			try {
				synDemo.out();
			} catch (InterruptedException e) {
				// TODO Auto-generated catch block
				e.printStackTrace();
			}
		}).start();
		
		new Thread(() -> {
			try {
				synDemo2.out();
			} catch (InterruptedException e) {
				// TODO Auto-generated catch block
				e.printStackTrace();
			}
		}).start();
	}
}
//-----运行会同时输出输出,因为锁住的是对象的实例-----//
  • 修饰静态方法:锁住整个类;
public class SynDemo {
	
	public static synchronized void staticOut() throws InterruptedException {
		System.out.println(Thread.currentThread().getName());
		Thread.sleep(5000);
	}
	
	
	public static void main(String[] args) {
		SynDemo synDemo = new SynDemo();
		SynDemo synDemo2 = new SynDemo();
		
		new Thread(() -> {
			try {
				synDemo.staticOut();
			} catch (InterruptedException e) {
				// TODO Auto-generated catch block
				e.printStackTrace();
			}
		}).start();
		
		new Thread(() -> {
			try {
				synDemo2.staticOut();
			} catch (InterruptedException e) {
				// TODO Auto-generated catch block
				e.printStackTrace();
			}
		}).start();
	}
}
//-----会先输出一个,休眠5s再输出第二个,说明锁住的是这个类-----//
  • 修饰代码块:锁住一个对象,即synchronized (lock)括号里的内容
public class SynDemo {

	private Object lock = new Object();
	
	public void myOut() {
		synchronized (lock) {
			System.out.println(Thread.currentThread().getName());
			try {
				Thread.sleep(5000);
			} catch (InterruptedException e) {
				// TODO Auto-generated catch block
				e.printStackTrace();
			}
		}
	} 
	
	public static void main(String[] args) {
		SynDemo synDemo = new SynDemo();
		
		new Thread(() -> {
			synDemo.myOut();
		}).start();
		
		new Thread(() -> {
			synDemo.myOut();
		}).start();
	}
}
//-----会先输出一个,休眠5s再输出第二个,是锁住一个对象-----//
3. volatile关键字
  • 能且只能修饰变量;
  • 保证该变量的可见性,但不保证原子性,不使用锁;
  • 禁止指令重排。

A、B两个线程同时读取volatile关键字修饰的变量,A读取后修改了变量的值,修改够的值对B线程来说是可见的。

  • 使用场景
    • 修饰一个变量作为线程开关(见上线程中断部分);
    • 单例模式里修饰对象实例,禁止指令重排
4. 单例与线程安全
4.1. 饿汉式

在类加载的时候就已经进行实例化,无论之后用不用得到。缺点是容易造成资源浪费本身线程安全

4.2. 懒汉式

在需要时再实例化,要想线程安全可以用synchronized关键字。

public class LazySingleton {
	//new的操作是很多的,可能会指令重排,所以用volatile
	private static volatile LazySingleton lazySingleton = null;
	
	private LazySingleton() {
		
	}
	
	public static synchronized LazySingleton getInstance() {
		//判断实例是否为空,为空则实例化
		if(lazySingleton == null) {
			lazySingleton = new LazySingleton();
		}
		//否则直接返回
		return lazySingleton;
	}
	
}

但把synchronized加在方法上比较消耗性能,所以有一种改进为双重校验

public class LazySingleton {
	//new的操作是很多的,可能会指令重排,所以用volatile
	private static volatile LazySingleton lazySingleton = null;
	
	private LazySingleton() {
		
	}
	
	public static synchronized LazySingleton getInstance() {
		//判断实例是否为空,为空则实例化
		if(lazySingleton == null) {
			synchronized (LazySingleton.class) {
				if(lazySingleton == null) {
					lazySingleton = new LazySingleton();
				}
			}
		}
		//否则直接返回
		return lazySingleton;
	}
	
}
5. 如何避免线程不安全
5.1. 线程安全性的成因
  • 多线程环境;
  • 多个线程操作同一共享资源;
  • 对该共享资源进行了非原子性操作。
5.2. 如何避免

打破成因中三点任意一点即可

  • 多线程环境----将多线程改成单线程;

在必要的代码块进行加锁访问。

  • 多个线程操作同一共享资源----不共享资源;

使用ThreadLocal技术、不共享资源、操作无状态化。

  • 对该共享资源进行了非原子性操作----将非原子性操作改成原子性操作。

加锁、或者使用JDK自带的原子性操作类、JUC提供的相应的并发工具类。

四、锁

1. 锁的分类
  • 自旋锁:线程状态及上下文切换消耗系统资源,当访问共享资源时间短时,频繁上下文切换不值得。JVM在实现时,使线程在没有获得锁的时候不被挂起,转而执行空循环,循环几次之后如果还没有获得锁,则被挂起;
  • 阻塞所:阻塞锁改变了线程的运行状态,让线程进入阻塞状态进行等待,当获得相应的信号(唤醒或者时间)时,才可以进入线程的准备就绪状态,转为就绪状态的所有线程通过竞争进入运行状态;
  • 重入锁:支持线程再次进入锁;
  • 读写锁:两把锁,读锁跟写锁,写写互斥、读写互斥、读读共享
  • 互斥锁:类似一个人关了门别的人都进不来;
  • 悲观锁:总是假设最坏的情况,每次拿数据都认为别人会修改,所以每次拿数据的时候都会上锁;
  • 乐观锁:每次拿数据的时候都认为别人不会修改它,但是在更新的时候会判断一下在此期间有没有人去更新这个数据,可以使用版本号等机制
  • 公平锁:大家都老老实实排队,对大家都很公平;
  • 非公平锁:一部分人排着队,但新来的可以插队;
  • 偏向锁:使用了一种等到竞争出现才释放锁的机制,所以当其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁;
  • 独占锁:独占锁模式下每次只能有一个线程持有锁;
  • 共享锁:允许多个线程同时获取锁,并发访问,共享资源。
2. Lock接口
2.1. Lock的使用

基本步骤是先创建一把锁,然后用说锁的luck()和方法锁住需要并发保护的非原子性操作,结束后再用unlock()释放锁;

private static Lock lock = new ReentrantLock();
	
public static void inCreate() {
	lock.lock();
	num++;
	lock.unlock();
}
2.2. lock与synchronized的区别
  • lock获取锁和释放锁都需要手动操作;synchronized托管给JVM执行;
  • lock用的是乐观锁机制;synchronized原始采用的是cpu悲观锁机制,即线程获得的是独占锁;
  • synchronized在jkd1.6后优化了很多,自旋锁、锁消除、锁粗化、轻量级所等等,性能大大提高
2.3. 实现Lock接口的锁

WriteLockReentrantLockReadLockWriteLockViewReadLockView

2.4. 各个方法的简介
  • lock()unlock是一对上锁和释放锁的操作,不允许其他的线程去调用中断来中断它获取锁,会继续尝试获取锁;
  • lockInterruptibly()也是一个上锁的操作,但它允许在等待获取锁的的时候由其他线程去调用Thread.interrupted()这个方法去中断然后直接返回,这样就不用获取锁直接抛出中断异常;
  • tryLock()尝试去获取锁,佛系,可以设定一个时间,在这个时间内去尝试获取锁;
  • newCondition()提供一个类似wait()notify()的机制。
2.5. 实现自己的锁
  • 最简单的就是实现lock()unlock()方法,可以用wait()notify()配合flag的方法,注意wait()notify()方法在操作时要持有锁,所以要使用synchronized关键字。
package MyLock;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.Lock;

public class MyLock implements Lock{

	private boolean isHoldLock = false;
	
	/**
	 *  同一时刻能且仅能有一个线程能获取到锁,
	 *  其他线程只能等待该线程释放锁之后才能获取。
	 *  wait和notify方法在操作时要持有锁,所以要用synchronized关键字。
	 */
	@Override
	public synchronized void lock() {
		// TODO Auto-generated method stub
		if(isHoldLock) {
			try {
				wait();
			} catch (InterruptedException e) {
				// TODO Auto-generated catch block
				e.printStackTrace();
			}
		}
		
		isHoldLock = true;
	}

	@Override
	public synchronized void unlock() {
		// TODO Auto-generated method stub
		notify();
		isHoldLock = false;
	}

	//........
	//........
	//........
}
  • 但这样存在一个问题:重入性。例如如下例子,
public class ReentryDemo {
	
	public Lock lock = new MyLock();
	
	public void methodA() {
		lock.lock();
		System.out.println("进入方法A");
		methodB();
		lock.unlock();
	}
	
	public void methodB() {
		lock.lock();
		System.out.println("进入方法B");
		lock.unlock();
	}
	
	public static void main(String[] args) {
		ReentryDemo reentryDemo = new ReentryDemo();
		reentryDemo.methodA();
	}
}
//输出只有"进入方法A",在methodB里的一直等待methodA释放锁

所以要增加相应的信息:重入次数和保存持有锁的线程的线程

public class MyLock implements Lock{

	private boolean isHoldLock = false;
	//重入次数
	private int reentryCount = 0;
	//要有一个东西保存持有锁的线程
	private Thread holdLockThread = null;
	
	/**
	 *   同一时刻能且仅能有一个线程能获取到锁,
	 *  其他线程只能等待该线程释放锁之后才能获取。
	 *  wait和notify方法在操作时要持有锁,所以要用synchronized关键字。
	 */
	@Override
	public synchronized void lock() {
		//改进:锁已经被持有,而且当前线程不是持有锁的线程就要等待
		if(isHoldLock && Thread.currentThread() != holdLockThread) {
			try {
				wait();
			} catch (InterruptedException e) {
				// TODO Auto-generated catch block
				e.printStackTrace();
			}
		}
		//更新信息
		holdLockThread = Thread.currentThread();
		reentryCount++;
		isHoldLock = true;
	}

	@Override
	public synchronized void unlock() {
		//先判断当前线程是不是持有锁的线程
		//是,重入次数-1
		//不是,就不处理
		if(Thread.currentThread() == holdLockThread) {
			reentryCount--;
			//不能直接唤醒,要判断
			if(reentryCount == 0) {
				notify();
				isHoldLock = false;
			}
		}
	}

	//.......
	//.......
	//.......
}
2.6. AbstractQueuedSynchronizer(AQS)类浅析
  • 为实现依赖先进先出(FIFO)等待队列的阻塞锁和相关同步器(信号量、事件等等)提供的一个框架;
  • 此类的设计目标是成为依赖单个原子int值来表示状态的大多数同步器的一个有用基础;

类似MyLock里的状态信息。

  • 子类必须定义更改此状态的受保护方法,并定义哪种状态对于此对象意味着被获取或被释放;
  • 假定这些条件之后,此类中的其他方法就可以实现所有派对和阻塞机制。子类可以维护其他状态字段,但只是为了获得同步二追踪使用getState()setState(int)compareAndSetState(int,int)方法来操作以原子方式更新的int值;
  • 应该将子类定义为非公共内部帮助类,可用它们来实现其封闭类的同步属性。类AbstractQueuedSynchronizer没有实现任何同步接口,而是定义了诸如acquireInterruptibly(int)之类的一些方法,在适当的时候可以通过具体的锁和相关同步器来调用它们,以实现其公共方法;
  • 此类支持默认的独占模式和共享模式之一,或者二者都支持。处于独占模式下时,其他线程试图获取该锁将无法成功。在共享模式下,多个线程获取某个锁可能会成功。此类机械地意识到当在共享模式下成功获取某一锁时,下一个等待线程(如果存在)也必须明确自己是否可以成功获取该锁。处于不同模式下的等待线程可以共享相同的FIFO队列。通常实现子类只支持其中一种模式,但两种模式都可以在例如ReadWriteLock中发挥作用。只支持独占模式或者共享模式的之类不必定义支持未使用模式的方法;
  • 此类为内部队列提供了检查、检测和监视方法,还为condition对象提供了类似方法,可以根据需要使用用于其同步机制的AbstractQueuedSynchronizer将这些方法导出到类中;
2.7. ReentrantLock源码之非公平锁的实现

非公平锁的弊端:可能导致后面排队等待的线程等不到相应的cpu资源,造成线程饥饿。

2.8. ReentrantLock源码非公平锁的实现
2.9. 线程执行顺序之多线程Debug
2.10. 读写锁的特性以及ReentrantReadWriteLoc的使用
  • 特性:写写互斥、读写互斥、读读共享;
  • 锁降级:写线程获取写入锁后可以获取读取所,然后释放写入锁,这样就从写入锁变成了读取锁,从而实现了锁降级的特性;
  • 使用方式:
public class ReentrantReadWriteLockDemo {
	private int i = 0;
	private int j = 0;
	
	private ReadWriteLock lock = new ReentrantReadWriteLock();
	Lock readLock = lock.readLock();
	Lock writeLock = lock.writeLock();
	
	public void out() {
		readLock.lock();
		try {
			System.out.println(Thread.currentThread().getName() + "i的值===" + i + " j的值===" + j);
		} finally {
			readLock.unlock();
		}
	}
	
	public void inCreate() {
		writeLock.lock();
		i++;
		try {
			Thread.sleep(500);
			j++;
		} catch (InterruptedException e) {
			// TODO Auto-generated catch block
			e.printStackTrace();
		} finally {
			writeLock.unlock();
		}
		
		
	}
	
	public static void main(String[] args) {
		ReentrantReadWriteLockDemo reentrantReadWriteLockDemo = new ReentrantReadWriteLockDemo();
		for(int i = 0;i < 3;i++) {
			new Thread(() ->{
				reentrantReadWriteLockDemo.inCreate();
				reentrantReadWriteLockDemo.out();
			}).start();
		}
	}
}
2.11. AQS如何用单一int值表示读写两种状态
  • int是32位,将其拆分成两个无符号short高16位表示读锁,低16位表示写锁;
  • 两种锁的最大重入次数均为65535也即2的16次方减去1
  • 读锁:每次都从当前的状态上 +65536,即0000000000000001 0000000000000000获取读锁的个数就将state整个无符号右移16位即可得出读锁的个数;
  • 写锁:每次都直接 +1获取写锁的时候就整个与上65535,即与上0000000000000000 1111111111111111;
2.12. 锁降级
  • 写锁线程获取写入锁后可以获取读取锁,然后释放写入锁,这样就从写入锁变成了读取锁,从而实现了锁降级的特性。

注意点:锁降级之后,写锁并不会直接降级成读锁,不会随着读锁的释放而释放,还需要显式释放

public class LockDegrade {
	
	public static void main(String[] args) {
		ReentrantReadWriteLock reentrantReadWriteLock = new ReentrantReadWriteLock();
		Lock readLock = reentrantReadWriteLock.readLock();
		Lock writeLock = reentrantReadWriteLock.writeLock();
		
		writeLock.lock();
		readLock.lock();
		writeLock.unlock();
		readLock.unlock();
		
		System.out.println("程序运行结束");
	}
	
}
//---正常输出----
  • ReentrantReadWriteLock里面,不存在锁升级,即在获取读锁后不能再获取写锁
  • 锁降级的应用场景:用于对数据比较敏感,需要在对数据修改后获取到修改后的值从而进行下一步的操作;
2.12. StampedLock原理及使用
  • 为什么要有StampedLock:大部分应用都是读多写少,ReentrantReadWriteLock读写互斥,读的时候会阻塞写,写操作本来就少还被阻塞会降低系统性能,而且资源可能大部分往读操作分配,容易造成线程饥饿。

  • StampedLock的特点

    • 所有获取锁的方法都返回一个邮戳Stamp,为0表示获取失败,为1表示成功;
    • 所有释放锁的方法都需要一个邮戳,而且必须和成功获取锁时得到的邮戳一致;
    • 不仅支持锁降级,还支持锁升级;
    • 不可重入,如果一个线程已经持有了写锁,再去获取写锁的话就会死锁;
    • 可以乐观读也可以悲观读;
    • 乐观读不阻塞写操作,悲观读阻塞写操作;
    • 使用有限次自旋,增加锁获得的几率,避免上下文切换带来的开销。
  • StampedLock的优点:相比ReentrantReadWriteLock,吞吐量大幅提升;

  • StampedLock的缺点:API响度复杂,容易用错;内部实现也较为复杂。

  • StampedLock的原理:每次获取锁的时候都会返回一个邮戳stamp,相当于mysql里的version字段。释放锁的时候再根据获得的邮戳去进行释放。

  • StampedLock的注意点:如果使用乐观读,一定要判断返回的邮戳是否是一开始获得到,如果不是,要去获取悲观读锁,再次去读取。

五、线程间通信

1. wait、notify、notifyAll
1.1. 何时使用

在多线程环境下,有时候一个线程的执行依赖于另一个线程的某种状态的改变,这个时候可以使用waitnotify

这里也可使用空轮询,但这样会浪费cpu资源,而且等待时间不可控。

1.2. wait和sleep的区别

wait会释放所持有的锁,而sleep不会,sleep只是让线程在指定的时间内不去抢占cpu的资源。

1.3. 注意点

waitnotify必须放在同步代码块里,并且必须持有当前对象的锁,即不能取得A对象的锁而调用B对象的wait。哪个对象wait就得调用哪个对象的notify

1.4. notify和notifyAll的区别
  • notify随机唤醒一个等待的线程;
  • notifyAll唤醒所有的等待的线程。
2. 等待通知经典模型之生产者消费者
sequenceDiagram
生产者->>中间商: 生产。
中间商->>生产者: 库存满,则通知生产者暂停生产;等到有被消费了再通知生产者继续生产。
中间商->>消费者: 库存不足,则通着消费者暂停消费;等有库存了再让消费者进行消费。。
消费者->>中间商: 消费。

3. 使用管道流进行通信

以内存为媒介,用于线程之间的数据传输。

  • 有面向字节的:PipedOutputStreamPipedInputStream;
  • 面向字符的:PipedReaderPipedWriter;
4. Thread.join通信及源码
4.1. 使用场景

线程A执行到一半,需要一个数据,这个数据需要线程B去执行修改,只有B修改完成之后A才能继续操作。

在线程A的run方法里面调用线程B的join方法,这个时候线程A会等待线程B运行完成后再继续运行。

4.2. 源码分析
  • 有两个构造方法,一个带参一个不带参,可以传入一个时间;
  • 里面调用了wait方法。
5. ThreadLocal的使用
5.1. 定义

是一种线程变量,是一个以ThreadLocal对象为键,任意对象为值的存储结构。为每一个线程单独存放一份变量副本,也就是说一个线程可以根据一个ThreadLocal对象查询绑定到这个线程上的一个值。
只要线程处于活动状态并且ThreadLocal实例可访问,那么每个线程都拥有对其本地线程副本的隐式引用变量,一个线程消失后,它的所有线程副本局部实例受垃圾回收,除非其他存在对这些副本的引用。

5.2. 常用方法
  • ThreadLocal.get:获取ThreadLocal中当前线程共享变量的值;
  • ThreadLocal.set:设置ThreadLocal中当前线程共享变量的值;
  • ThreadLocal.remove:移除ThreadLocal中当前线程共享变量的值;
  • ThreadLocal.initialValueThreadLocal没有被当前线程复制时或当前线程刚调用remove方法后调用get方法,返回此方法值。
6. Condition的使用
6.1. 作用

可以在一个锁里面,存在多种等待条件。

6.2. 主要方法
  • await
  • signal
  • siginalAll

六、原子类、

1. 什么是原子类

原子类可以认为其操作都是不可分割的

为什么要有原子类?
多线程访问同一个变量,我们需要加锁,而锁是比较消耗性能的。
JDK1.5之后新增的原子操作类提供了一种用法简单、性能高效、线程安全地更新一个变量的方式。
这些类同样位于JUC包下的atomic包下,发展到JDK1.8,该包下共17个类,囊括了原子更新基本类型、原子更新数组、原子更新属性、原子更新引用。

Jdk1.8新增的原子类
DoubleAccumulatorDoubleAdderLongAccumulatorLongAdderStriped64

2. 原子更新基本类型

发展至JDK1.8,基本类型原子类有以下几个:
AtomicBooleanAtomicIntegerAtomicLOngDoubleAccumulatorDoubleAdderLongAccumulatorLongAdder
大致可以归为3类:

类型特点
AtomicBooleanAtomicIntegerAtomicLOng元老级的原子更新,方法几乎一摸一样
DoubleAdderLongAdderDoubleLong的原子更新性能进行优化提升
DoubleAccumulatorLongAccumulator支持自定义运算
3. 原子更新数组类型

AtomicIntegerArrayAtomicLongArrayAtomicReferenceArray

4. 原子更新属性

原子地更新某个类的某个字段时,就需要使用原子更新字段类,Atomic包提供了以下4个类进行原子字段更新
AtomicIntegerFieldUpdaterAtomicLongFieldUpdaterAtomicStampedReferenceAtomicReferenceFieldUpdater

注意:

  • 需要原子更新的字段要用volatile关键字修饰,在线程之间共享变量时保证立即可见;
  • 字段的描述类型是与调用者与操作对象字段的关系一致,也就是说调用者能够直接操作对象字段,那么就可以反射进行原子操作;
  • 对于一个父类的字段,子类不能直接操作;
  • 更新的变量只能是一个实例的可修改的变量,不能是类的变量和不可变变量,即变量不能用staticfinal修饰;
  • AtomicIntegerFieldUpdaterAtomicLongFieldUpdater只能修改基本类型的变量,不能是它的包装类,如果要修改包装类,就要用AtomicReferenceFieldUpdater
5. 原子更新引用类型
  • AtomicReference:用于对引用的原子更新;
  • AtomicMarkableReference:会返回版本戳的原子引用类型,版本戳为boolean类型;
  • AtomicStampedReference:会返回版本戳的原子引用类型,版本戳为int类型。
public class AtomicReferenceDemo {
	
	public static void main(String[] args) {
		
		AtomicReference<Student1> studentAtomicReference 
			= new AtomicReference<>();
		
		Student1 student = new Student1(1L, "xdclass");
		Student1 student1 = new Student1(2L, "wiggin");
		
		//在cas之前要先set,不然最后会有空指针异常
		studentAtomicReference.set(student);
		studentAtomicReference.compareAndSet(student, student1);
		//更新完后要通过get方法获得
		Student1 student2 = studentAtomicReference.get();
		
//		System.out.println(student.getName());   //输出------>xdclass ,原对象没变
//		System.out.println(student1.getName());  //输出------>wiggin ,原对象没变
		System.out.println(student2.getName());  //输出------>wiggin
		
	}
}

class Student1{
	private long id;
	private String name;
	
	public Student1(long id, String name) {
		super();
		this.id = id;
		this.name = name;
	}

	public long getId() {
		return id;
	}
	public void setId(long id) {
		this.id = id;
	}

	public String getName() {
		return name;
	}
	public void setName(String name) {
		this.name = name;
	}

}

七、容器

1. 同步容器和并发容器
1.1. 同步容器
  • VectorHashTable:JDK提供的同步容器类;
  • Collections.synchronizedXXX:本质是对相应的容器进行包装。

同步容器类的缺点:

  • 在单独使用里面的方法时,可以保证线程安全,但是复合操作需要额外加锁来保证线程安全。
  • 在使用Iterator迭代容器或使用for-each遍历容器,在迭代过程中修改容器会抛出ConurrentModificationException异常。
  • 想要避免出现ConurrentModificationException异常就必须在迭代过程中持有容器的锁。但若容器较大,则迭代时间也会较长,那么需要访问该容器的其他线程将会长时间等待,从而降低性能。
  • 若不希望在迭代期间对容器加锁,可以使用“克隆”容器的方式。使用线程封闭,由于其他线程不会对容器进行修改,可以避免ConurrentModificationException异常。但在创建副本时存在较大的性能开销;
  • toStringhashCodeequalscontainsAllremoveAllretainAll等方法都会隐式调用Iterator,也可能抛出ConurrentModificationException异常。
2. 并发容器

根据具体场景进行设计,尽量避免使用锁,提高容器的并发访问性能。
主要有三大类:

  • CopyOnWrite
  • Concurrent
  • BlockingQueue

CopyOnWrite主要使用克隆技术,操作一个副本;
ConcurrentBlockingQueue是基于queue实现的FIFO队列。队列为空,取操作会被阻塞; ConcurrentBlockingQueue对列为空时取的时候直接返回空。

3. LinkedBlockingQueue的使用及其源码

在并发编程里,LinkedBlockingQueue的使用非常频繁,它可以作为生产者消费者的中间商

  • add():实际上调用offer,区别是在队列满的时候会报异常;
  • offer():如果队列满了,直接入队失败;
  • put():在队列满的时候,会进入阻塞状态;
  • remove():直接调用poll,唯一的区别是remove在队列为空的时候会抛出异常;
  • poll():队列为空时直接返回nul;
  • take():队列为空时,会进入等待状态。

八、并发工具类

CountDownLatchCyclicBarrierSemaphore等很多并发工具类都是基于AQS框架实现的,从它们的源码可以发现,这些类的内部都有一个继承了AbstractQueuedSynchronizer 的内部类Sync。详见
JAVA并发编程:CAS和AQSJAVA并发之AQS详解

1. CountDownLatch

一个可以用来协调多个线程之间的同步,或者说起到线程之间的通信作用的工具类。

它能够使一个线程在等待另外一些线程完成各自工作之后,再继续执行。使用一个计数器进行实现。计数器初始值为线程的数量。当每一个线程完成自己任务后,计数器的值就会减一。当计数器的值为0时,表示所有的线程都已经完成了任务,然后在CountDownLatch上等待的线程就可以恢复执行任务。

在new一个CountDownLatch的时候会传入一个数字,每当一个线程执行完毕,就调用countDown()将计数器减一,当计数器的值变为0时,在CountDownLatchawait()的线程就会被唤醒。

应用场景:启动一个服务的时候,主线程需要等待多个组件加载完毕之后再执行。

例如八个选手进行800米跑,要等他们进行完才能开始下一个项目跨栏比赛。

public class CountDownLatchDemo {
	
	public static void main(String[] args) {
		
		CountDownLatch countDownLatch = new CountDownLatch(8);
		//主线程
		new Thread(() -> {
			try {
				countDownLatch.await();
			} catch (InterruptedException e) {
				// TODO Auto-generated catch block
				e.printStackTrace();
			}
			System.out.println("800米比赛结束,准备清空跑到进行跨栏。");
		}).start();
		
		//8个选手跑800米
		for(int i = 0;i < 8;i++) {
			int finalI = i;
			new Thread(() -> {
				try {
					Thread.sleep(finalI * 1000);
					System.out.println(Thread.currentThread().getName() + "到达800米终点");
				} catch (InterruptedException e) {
					// TODO Auto-generated catch block
					e.printStackTrace();
				} finally {
					//模拟可能有选手退出,无论跑没跑完
					countDownLatch.countDown();
				}
			}).start();
		}
	}
}
/**
--------结果---------
Thread-1到达800米终点
Thread-2到达800米终点
Thread-3到达800米终点
Thread-4到达800米终点
Thread-5到达800米终点
Thread-6到达800米终点
Thread-7到达800米终点
Thread-8到达800米终点
800米比赛结束,准备清空跑到进行跨栏。
*/
2. CyclicBarrier–栅栏

允许一组线程相互等待达到一个公共的障碍点,之后再继续执行。

例如800米跑项目要等所有选手都到起跑线就位才会开始。

public class CyclicBarrierDemo {
	
	public static void main(String[] args) {
		
		CyclicBarrier cyclicBarrier = new CyclicBarrier(8);
		
		for(int i = 0;i < 8;i++) {
			int finalI = i;
			new Thread(() -> {
				try {
					Thread.sleep(finalI * 1000);
				} catch (InterruptedException e1) {
					e1.printStackTrace();
				}
				System.out.println(Thread.currentThread().getName() + "一道起跑线准备就绪");
				try {
					//每个线程都运行到这里开始等待,直到全部到达这里再继续
					cyclicBarrier.await();
				} catch (InterruptedException | BrokenBarrierException e) {
					// TODO Auto-generated catch block
					e.printStackTrace();
				}
				
				System.out.println("选手全部就绪,准备比赛");
			}).start();
		}
	}
}
/**
------结果------
Thread-0达到起跑线准备就绪
Thread-1达到起跑线准备就绪
Thread-2达到起跑线准备就绪
Thread-3达到起跑线准备就绪
Thread-4达到起跑线准备就绪
Thread-5达到起跑线准备就绪
Thread-6达到起跑线准备就绪
Thread-7达到起跑线准备就绪
选手全部就绪,准备比赛
选手全部就绪,准备比赛
选手全部就绪,准备比赛
选手全部就绪,准备比赛
选手全部就绪,准备比赛
选手全部就绪,准备比赛
选手全部就绪,准备比赛
选手全部就绪,准备比赛
*/

跟CountDownLatch的区别

  • CountDownLatch一般用于某个线程等待若干个线程执行完成之后,它再运行。不可重复使用
  • CyclicBarrier一般用于一组线程相互等待至某个状态,然后这一组线程再同时执行。可重用
3. Semaphore–信号量

用来控制并发数量

使用场景:接口限流

public class SemaphoreDemo {
	
	public static void main(String[] args) {
		Semaphore semaphore = new Semaphore(8);
		
		for(int i = 0; i < 10;i++) {
			new Thread(() -> {
				try {
					//执行前调用acquire,需要try-catch
					semaphore.acquire();
					System.out.println(Thread.currentThread().getName() + "开始执行");
					Thread.sleep(5000);
				} catch (InterruptedException e) {
					// TODO Auto-generated catch block
					e.printStackTrace();
				} finally {
					//使用完要释放
					semaphore.release();
				}
			}).start();
		}
	}
}
/**
-----结果-----
Thread-0开始执行
Thread-3开始执行
Thread-2开始执行
Thread-1开始执行
Thread-5开始执行
Thread-4开始执行
Thread-7开始执行
Thread-6开始执行
-------休眠5s后8、9才有执行权-----
Thread-8开始执行
Thread-9开始执行

*/
4. Exchanger

它提供一个同步点,在这个同步点两个线程可以交换彼此的数据
这两个线程通过exchange方法交换数据,如果第一个线程先执行exchange方法,它会一直等待第二个线程也执行exchange,当两个线程到达同步点时,这两个线程就可以交换数据,将本线程生产出来的数据传递给对方。
因此使用Exchanger的重点是对成对的线程使用exchange()方法因此该工具类的线程对象是成对的

public class ExchangerDemo {
	
	public static void main(String[] args) {
		Exchanger<String> stringExchanger = new Exchanger<>();
		
		String str1 = "xdclass";
		String str2 = "wiggin";
		
		new Thread(() -> {
			System.out.println(Thread.currentThread().getName() + " 初始值=========>" + str1);
			try {
				String exchange = stringExchanger.exchange(str1);
				System.out.println(Thread.currentThread().getName() + " 交换后的数据=========>" + exchange);
			} catch (InterruptedException e) {
				// TODO Auto-generated catch block
				e.printStackTrace();
			}
		},"线程1").start();
		
		new Thread(() -> {
			System.out.println(Thread.currentThread().getName() + " 初始值=========>" + str2);
			try {
				String exchange = stringExchanger.exchange(str2);
				System.out.println(Thread.currentThread().getName() + " 交换后的数据=========>" + exchange);
			} catch (InterruptedException e) {
				// TODO Auto-generated catch block
				e.printStackTrace();
			}
		},"线程2").start();
	}
}
/**
-----结果-----
线程1 初始值=========>xdclass
线程2 初始值=========>wiggin
线程2 交换后的数据=========>xdclass
线程1 交换后的数据=========>wiggin

*/

九、线程池及Executor框架

1. 为什么要使用线程池
  • 许多服务器应用程序常常会面临的一个应用场景是处理大量但短小的任务。即单个任务的处理时间很短但请求数目巨大。如果每当一个请求到达时就创建一个新线程来为请求服务,那么这种频繁地创建、销毁线程带来的系统开销非常大;
  • 线程池为线程生命周期问题和资源不足问题提供了解决方案。通过对多个任务重用线程,线程创建的开销分摊到了多个任务上;
  • 其好处是,因为请求到达时线程已近存在,这样就消除了线程创建带来的延迟,使程序响应更快;
  • 而且,通过适当地调整线程池中的线程数目,也就是当请求数目超过某个阈值时就强制其他任何新到的请求一直等待,直到获得一个线程来处理为止,从而防止资源不足。

风险与机遇
用线程池构建应用程序容易遭受任何其他多线程程序容易遭受的并发风险,如同步错误和死锁,它还容易遭受特定于线程池的少数其他风险,如与线程池有关的死锁、资源不足和线程泄露等。

2. 线程池的创建和使用
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值