目录
4.JUC(java.util.concurrent)的常见类
线程的特点:
1.每个线程都可以独立的去CPU上调度执行
2.同一个进程的多个线程之间,共用同一份内存空间和文件资源
进程和线程的区别(经典面试题)
1.进程包含线程。一个进程里面可以有一个线程,也可以有多个线程
2.进程和线程都是用来实现并发编程场景的,到那时线程比进程更轻量,更高效
3.同一个进程的线程之间,共用同一份的资源(内存+硬盘),省去了申请资源的开销
4.进程和进程之间是具有独立性的,一个进程挂了是不会影响其他进程的;线程和线程之间(前提是同一个进程内),是会相互影响的(线程安全问题+线程出现异常)
5.进程是资源分配的基本单位,线程是调度执行的基本单位
线程相关知识
run只是描述了线程的入口(线程要做什么任务)
start则是真正调用了系统API,在系统中创建出线程,让线程再调用run
//创建一个类,继承自Thread
class MyThread extends Thread{
@Override
public void run(){
//这个方法就是线程的入口方法
System.out.println("hello thread");
}
}
public static void main(String[] args){
Thread t=new MyThread();
t.start();
//如果把t.start改成t.run。此时代码中不会创建出新的线程,只有一个主线程,这个主线程里面只能依次执行循环,执行完一个循环再执行另一个(无法做到并发执行的效果)
}
Thread.sleep();,sleep是Thread中的静态方法
创建线程的写法:
1.继承Thread,重写run class MyThread extends Thread
2.实现Runnable,重写run //该方法可以做到解耦合 class MyRunnable implements Runnable
//在main中写
Runnable runnable=new MyRunnable();
Thread t=new Thread(runnable);
t.start();
3.继承Thread,重写run,但是使用匿名内部类
Thread t=new Thread(){
@Override
public void run(){
System.out.println("hello thread");
}
};
4.实现Runnable,重写run,但是使用匿名内部类
5.基于lambda表达式:
Thread t=new Thread(()->{
System.out.println("hello thread");
});
//(比较推荐的写法)
Thread类的使用
Thread()//创建线程对象
Thread(Runnable target)//使用Runnable对象创建线程对象
Thread(String name)//创建线程对象,并命名
Thread(Runnable target,Runnable target)//使用Runnable对象创建线程对象,并命名
Thread(ThreadGroup group,Runnable target)//线程可以被用来分组管理,分好的组即为线程组
Thread类中的方法
getId()获取id
getName()获取名称
getState()获取状态
getPriority()获取优先级
isDaemon()获取是否后台线程
isAlive()获取是否存活
isInterrupted()获取是否被中断
后台线程不影响整个进程的结束,前台线程影响整个进程的结束
设置t为后台线程:t.setDaemon(true);
中断(终止/打断)一个线程
interrupt
在Java中,要销毁/终止线程,做法是比较唯一的,就是想办法让run方法尽快执行结束
//通过利用成员变量作为标志位来判断是否终止线程{
1.需要手动创建变量
2.当线程内部在sleep的时候,主线程修改变量,新线程内部不能及时响应
}
变量捕获
lambda表达式有一个语法规则,变量捕获:lambda表达式里面的代码,是可以自动捕获到上层作用域中涉及到的局部变量的
Java中变量捕获语法还有一个前提限制,就是必须只能捕获一个final或者是实际上是final的变量
判定循环是否要结束
//Thread类内部,有一个现成的标志位,可以用来判定当前的循环是否要结束
while(!Thread.currentThread().isInterrupted())
\\t.interrupt(); 这个操作,就是把上述Thread对象内部的标志位设置为true(即使线程内部的逻辑出现阻塞(sleep)也是可以使用这个方法唤醒的;interrupt唤醒线程之后,此时sleep方法抛出异常同时会自动清除刚才设置的标志位,这样就使得“设置标志位”这样的效果就好像没有生效一样
//在捕获异常后的语句中加一个break可以让线程立即结束或者在里面做一些工作然后完成后再结束)
//如果没有sleep则没有上述操作空间
线程等待
让一个线程等待另一个线程执行结束再继续执行,本质上就是控制线程结束的顺序
join实现线程等待效果
主线程中调用t.join(),此时就是主线程等待t线程先结束
实际开发中带有超时时间,t.join(xxx);//单位为毫秒
线程的状态
NEW:Thread对象已经有了,start方法还没调用
TERMINATED:Thread对象还在,内核中的线程已经没了
RUNNABLE:就绪状态(线程已经在cpu上执行了/线程正在排队等待上cpu执行)
TIMED_WAITING:阻塞,由于sleep这种固定时间的方式产生的阻塞
WATING:阻塞,由于wait这种不固定时间的方式产生的阻塞
BLOCKED:阻塞,由于锁竞争导致的阻塞
线程安全
如果多个线程同时并发执行,由于线程之间的调度顺序是随机的,就会导致在有些调度顺序下逻辑出现问题
产生线程安全问题的原因
1.操作系统中,线程的调度顺序时随机的(抢占式执行)
2.两个线程针对同一个变量进行修改
3.修改操作不是原子的
4.内存可见性问题
5.指令重排序问题
如何给java中的代码加锁呢?
其中最常用的办法就是使用synchronized关键字
synchronized在使用时,要搭配一个代码块{}
在进入 { 就会加锁,出了 } 就会解锁
在已经加锁的状态下另一个线程尝试同样加这个锁,就会产生“锁冲突/锁竞争”,后一个线程就会阻塞等待,一直等到前一个线程解锁为止、
synchronized(){
count++;
}
//( )中需要表示一个用来加锁的对象,这个对象是啥不重要,重要的是通过这个对象来区分两个线程是否在竞争同一个锁。如果两个线程是在针对同一个对象加锁,就会有锁竞争;如果不是针对同一个对象加锁,就不会有锁竞争,仍然是并发执行
synchronized除了修饰代码块之外还可以修饰实例方法或者修饰静态方法
例:
synchronized public void increase(){
count++;
}
或者public void increase2(){
synchronized(this){
count++;
}
}
//上面写法是下面写法的简化版
如果是修饰静态方法,相当于是针对类对象加锁
synchronized public static void increase3(){
count++;
}
或者public static void increase4(){
synchronized(Counter.class){
//Counter.class是类对象
count++;
}
}
synchronized一个重要的特性,可重入的
所谓的可重入锁,指的是一个线程连续针对一把锁加锁两次,不会出现死锁,满足这个要求就是“可重入”,不满足就是“不可重入”
关于死锁
1.一个线程针对一把锁连续加锁两次,如果是不可重入锁就死锁了(synchronized不会出现,c++的std::mutex就是不可重入锁,就会出现死锁)
2.两个线程两把锁(此时无论是不是不可重入锁,都会死锁)例:家门口钥匙锁车里了,车钥匙锁家里了(此处的两个synchronized是嵌套关系不是并列关系,嵌套关系说明是在占用一把锁的前提下,获取另一把锁)
3.N个线程,M把锁(相当于2的扩充)
死锁的成因,涉及到四个必要条件
1.互斥使用(锁的基本特性):当一个线程持有一把锁之后,另一个线程也想获取到锁,就要阻塞等待
2.不可抢占(锁的基本特性):当锁已经被线程1拿到之后,线程2只能等待线程1主动释放,不能强行抢过来
3.请求保持(代码结构):一个线程尝试获取多把锁(先拿到锁1之后,再尝试获取锁2,获取的时候锁1不会释放)
4.循环等待/环路等待:等待的依赖关系形成环了
volatile关键字
//不能保证原子性
1.保证内存可见性
给isQuit加上volatile修饰,编译器就会禁止将isQuit存到寄存器中以至于其变成常量值无法通过内存修改从而产生bug
2.进行指令重排序
wait和notify
用于协调多个线程的执行顺序的
wait 等待,让指定线程进入阻塞状态
notify 通知,唤醒对应的阻塞状态的线程
触发wait
Object object=new Object();
synchronized(object){
System.out.println("wait之前");
//把wait要放到synchronized里面来调用,保证确实是拿到了锁的
object.wait();
System.out.println("wait之后");
}
wait会持续的阻塞等待下去,直到其他线程调用notify唤醒
其他线程:
synchronized(object){
System.out.println("进行通知");
object.notify();
}
notify和notifyAll
前者一次唤醒一个线程,后者一次唤醒全部线程
多线程的代码案例
1.单例模式->非常经典的设计模式(单个实例(对象))
(1) //线程安全
class Singleton{
private static Singleton instance=new Singleton();
//通过这个方法来获取到刚才的实例
//后续如果想使用这个类的实例,都通过getInstance方法来获取
public static Singleton getInstance(){
return instance;
}
//把构造方法设置为私有,此时类外面的其他代码就无法new出这个类的对象了
private Singleton(){ }
}
//在main中使用:Singleton s=Singleton.getInstance();
(2) //线程不安全
class SingletonLazy{
private static volatile SingletonLazy instance=null;
public static SingletonLazy getInstance(){
//单例模式的代码,只是在首次使用的时候会涉及到线程不安全的问题;应该在加锁语句的外层再引入一个if判定是否要加锁
if(instance==null){
sychronized(SingletonLazy.class){
if(instance==null){
instance=new SingletonLazy();
}
}
}
//第一个if用来判定是否需要加锁,第二个if用来判定是否需要new对象,只不过凑巧两者的判定条件相同
return instance;
}
private SingletonLazy(){ }
}
//这种写法在首次调用getInstance的时候才会真正去创建出实例(如果不调用,就不创建)
//指令重排序产生的优化可能导致出现bug,此时应该用volatile修饰instance从而保证instance在修改的过程中不会出现指令重排序的现象
2.阻塞队列
阻塞队列是一种特殊的队列
1)线程安全
2)带有阻塞特性
a)如果队列为空,继续出队列,就会发生阻塞,阻塞到其他线程往队列里添加元素为止
b)如果队列为满,继续入队列,就会发生阻塞,阻塞到其他线程从队列中取走元素为止
//阻塞队列最大的意义就是可以用来实现“生产者消费者模型”
阻塞队列的意义:1.降低耦合,2.削峰填谷
BlockingQueue<String> queue=new ArrayBlockingQueue<>() / LinkedBlockingQueue<>();
Queue这里提供的各种方法,对于BlockingQueue来说也可以使用,但是一般不建议使用这些方法,因为这些方法都不具备阻塞特性
//应该用的是:
put 阻塞式的入队列
take 阻塞式的出队列
//使用wait的时候使用while进行循环判定
3.定时器(日常开发常用的组件)
约定一个时间,时间到达之后执行某个代码逻辑
//导包import java.util.Timer;
Timer timer=new Timer();
//给定时器安排一个任务,预定在xxx时间去执行(两个参数,一个任务,一个预定时间)
timer.schedule(new TimerTask(){
@Override
public void run(){
System.out.println("执行定时器的任务");
}
},2000);
4.线程池ThreadPoolExecutor
两种典型的方法可以进一步提高效率
1.协程(轻量级编程):相比于线程,把系统调度的过程给省略了(程序员手工调度)。是当下一种比较流行的并发编程的手段,但是在Java圈子里协程不够流行
2.线程池
//
线程池创建
1.ExecutorService service=Executors.newCachedThreadPool();//线程数目是能够动态适应的
2.newFixedThreadPool(count);//固定数目的线程池,指定线程数目为count
3.newSingleThreadExecutor();//只有一个线程的线程池(用的不多)
4.newSchduledThreadPool();//相当于定时器,不是一个扫描线程负责执行任务栏而是有多个线程执行时间到的任务
添加任务
service.submit(new Runnable(){
@Override
public void run(){
System.out.println("hello");
}
});
拒绝策略
AbortPolicy 直接抛出异常
CallerRunsPolicy 新添加的任务由添加任务的线程负责执行
DiscardOldestPolicy 丢弃任务队列中最老的任务
DiscardPolicy 丢弃当前新加的任务
多线程进阶
1.常见的锁策略
1)乐观锁,悲观锁
如果预测接下来锁冲突的概率不大,就可以少做一些工作,就称为乐观锁
如果预测接下来锁冲突的概率很大,就应该多做一些工作,就称为悲观锁
2)重量级锁,轻量级锁
轻量级锁,锁的开销比较小
重量级锁,锁的开销比较大
3)自旋锁,挂起等待锁
自旋锁是轻量级锁的一种典型实现
挂起等待锁是重量级锁的一种典型实现
4)读写锁
把加锁分成了读加锁和写加锁两种
读加锁:读的时候,能读,但是不能写
写加锁:写的时候,不能读,也不能写
5)可重入锁,不可重入锁
一个线程针对同一把锁,连续加锁两次,不会死锁就是可重入锁,会死锁就是不可重入锁
6)公平锁,非公平锁
当很多线程去尝试加一把锁的时候,一个线程能够拿到锁,其他线程阻塞等待,一旦第一个线程释放锁之后,接下来是哪个线程能够重新拿到锁呢?
公平锁:就是按照先来后到的顺序
非公平锁:剩下线程以均等的概率来重新竞争锁
2.CAS(compare and swap)
比较交换的是内存和寄存器
例:比如有一个内存M,还有两个寄存器A和B
CAS(M,A,B)
如果M和A的值相同的话,就把M和B里的值进行交换,同时整个操作返回true
如果M和A的值不同的话,无事发生,同时整个操作返回false
//交换的本质是为了把B赋值给M(寄存器B里的值是啥我们不太关心,更关心的是M里的情况)
//CAS其实是一个cpu指令(一个cpu指令就能完成上述比较交换的逻辑)单个的cpu指令是原子的,就可以使用CAS完成一些操作从而进一步的替代“加锁”
1)实现原子类
2)实现自选锁
//ABA问题:在极端情况下可能有另一个线程穿插进来把值从A->B->A,针对第一个线程来说,看起来好像是这个值没变,但是实际上已经被穿插执行了(如果问题真的出现了,大部分情况下也不会产生bug//虽然另一个线程穿插执行了,由于值又改回去了,此时逻辑上也不一定会产生bug)//解决方案:只要让判定的数值按照一个方向增长即可(不要反复横跳)
针对像账户余额这种要求能增能减的概念,可以引入一个额外的变量“版本号”,约定每次修改余额时都让版本号自增,此时通过判定版本号是否变化来判断是否有线程穿插执行(如果穿插执行了应该回滚上一操作)
3.synchronized原理
1)锁升级
无锁->偏向锁->自旋锁(轻量级锁)->重量级锁
偏向锁不是真的加锁,只是做了一个标记
当锁冲突出现的时候,偏向锁就会升级成轻量级锁,就真正加锁了
偏向锁的核心思想就是“懒汉模式”的另一种体现,能不加锁就尽量不加锁,加锁意味着有开销
2)锁消除
编译器会自动针对你当前写的加锁的代码做出判定,如果编译器觉得这个场景不需要加锁就会把你写的synchronized给优化掉(触发的概率不算很高),这个过程是编译过程触发的,没到运行时
3)锁粗化
锁的粒度:synchronized里面代码越多就认为锁的粒度越粗,代码越少就认为锁的粒度越细
粒度细的时候,能够并发执行的逻辑更多,更有利于充分利用多核cpu资源,但是如果粒度细的锁被反复进行加锁解锁,可能实际效果还不如粒度粗的锁(涉及到反复的锁竞争)
4.JUC(java.util.concurrent)的常见类
1)Callable接口
也是一种创建线程的方式
适合于想让某个线程执行一个逻辑并且返回结果的时候
相比之下,Runnable不关注结果
例:
public static void main(String[] args){
//定义了任务
Callable<Integer> callable=new Callable<Integer>(){
@Override
public Integer call() throws Exception{
int sum=0;
for(int i=0;i<=1000;i++){
sum+=i;
}
return sum;
}
};
//把任务放到线程中进行执行
FutureTask<Integer> futureTask=new FutureTask<>(callable);
Thread t=new Thread(futureTask);
t.start();
//此处的get就能获取到callable里面的返回结果
//由于线程是并发执行的,执行到主线程的get的时候,t线程可能还没执行完
//没执行完的话,get就会阻塞
System.out.println(futureTask.get());
}
2)ReentrantLock
这是一个可重入锁,使用效果上和synchronized是类似的
优势:
(1)ReentrantLock,在加锁的时候有两种方式,lock和tryLock
(2)ReentrantLock,提供了公平锁的实现(默认情况下是非公平锁)
(3)ReentrantLock,提供了更强大的等待通知机制(搭配了Condition类,实现等待通知的)
3)信号量Semaphore
信号量就是一个计数器,描述了“可用资源”的个数
每次申请一个可用资源,就需要让计数器-1(P操作):acquire
每次释放一个可用资源,就需要让计数器+1(V操作):release
(这里的+1和-1都是原子的)
创建:Semaphore semaphore=new Semaphore(4);//信号量中的可用资源为4
4)CountDownLatch
主要适用于多个线程来完成一系列任务的时候,用来衡量任务的进度是否完成
主要有两个方法:
(1)await,调用的时候就会阻塞,就会等待其他线程完成任务,所有线程都完成任务后这个await才会返回然后接着往下走
(2)countDown,告诉CountDownLatch,我当前这个子任务已经完成了
创建:CountDownLatch countDownLatch=new CountDownLatch(10);//此时表示的是有10个任务,await就会在10次调用完countDown之后才能继续执行
5.线程安全的集合类
数据结构中大部分的集合类都是线程不安全的
Vector,Stack,Hashtable是线程安全的
1)多线程环境使用ArrayList
(1)自己使用同步机制(synchronized或者ReentrantLock)
(2)Collections.synchronizedList(new ArrayList);
这个东西会返回一个新的对象,这个新的对象就相当于给ArrayList套了一层壳,这层壳就是在方法上直接使用synchronized的
(3)使用CopyOnWriteArrayList(写时拷贝)
如果要是两个线程读,就直接读就好了
如果某个线程需要进行修改,就把ArrayList复制出一份副本,修改线程就去修改这个副本,与此同时另一个线程仍然可以读取数据(从原来的数据上进行读取),一旦修改完毕之后就会使用修改好的这份数据去替代掉原来的数据(往往就是一个引用赋值)
局限性:【1】当前操作的ArrayList不能太大(拷贝成本不能太高)【2】更适合于一个线程去修改,而不能时多个线程同时修改(多个线程读,一个线程修改)
//这种场景特别适合于“服务器的配置更新”,可以通过配置文件来描述配置的详细内容(本身就不会很大),配置的内容会被读到内存中,再由其他的线程读取这里的内容,但是修改配置内容往往只有一个线程来修改
2)多线程环境使用队列
(1)ArrayBlockingQueue
基于数组实现的阻塞队列
(2)LinkedBlockingQueue
基于链表实现的阻塞队列
(3)PriorityBlockingQueue
基于堆实现的带优先级的阻塞队列
(4)TransferQueue
最多只包含一个元素的阻塞队列
3)多线程环境使用哈希表
HashMap本身不是线程安全的
在多线程环境下使用哈希表可以使用:Hashtable和ConcurrentHashMap
【1】ConcurrentHashMap最核心的改进就是把一个全局的大锁改进成了每个链表独立的一把小锁,这样做大幅度降低了锁冲突的概率;【2】充分利用到了CAS特性,把一些不必要加锁的环节给省略加锁了;【3】针对读操作没有加锁,意味着读和读之间,读和写之间,都不会有锁竞争;【4】针对扩容操作,做出了单独的优化(化整为零:一旦需要扩容需要搬运的时候,不是在一次操作中搬运完成而是分成多次来搬运,每次只搬运一部分数据,避免这单次操作过于卡顿)