java并发编程实战

红色是面试点? 

如果多个线程访问一个对象的状态变量没有做同步措施,程序就可能出现错误。可以弥补的措施有:

1、状态变量不在线程之间共享

2、将状态修改为不可变的变量

3、访问该状态变量的时候使用同步(似乎和问题条件冲突)

当设计线程安全的类时,良好的面向对象技术、不可修改性一级明细的不变性规范都能起到作用
面向对象的抽象和封装会降低性能
使用线程安全的类可以避免去纠结线程安全问题
线程安全的定义:当多个线程访问某个类时,不管是什么调度方式或者线程交替执行,在主调代码中不需要额外的同步或协同,这个类都能表现出正确的行为。这个类就是线程安全的。
无状态的类一定是线程安全的。

count++到底做了什么?

count++在指令层面做了 读取-修改-写入 三个步骤,这三个步骤是一个操作序列,在多线程中可能因为多个线程读取了初始值,A线程修改了值,但是B和C线程仍是在初始值的基础上做修改,读取修改写入是竞态条件的一种典型情况。

竞态条件(raceCondition)

竞态条件我觉得翻译成竞态现象更贴切一些。由于执行时序不同导致错误结果的现象。最常见的竞态现象是 检查——执行(CHECK-THEN-ACT),检查是观察值的情况,执行是采取动作,由于检查的时候失去了时间片,其他线程对数据做了修改,此时在拿到时间片去执行,基于的数据就是错误的。即基于错误的观察结果而执行动作。

读取修改写入和检查执行是常见的竞态现象,读取修改写入常用于修改已有的值,在赋值过程中读取的值发生了变化导致原来基于的数据是错误的,而检查执行就是基于错误的观察结果去执行代码。

竞态现象的实例:延迟初始化

public class LazyInitRace{

    private ExpensiveObj instance = null;

    public ExpensiveObj getInstance(){

        if(instance==null){

            instance = new ExpensiveObj();

        }

        return instance;

    }

}

如果A B线程在该类没有初始化的情况下同时去执行获取实例方法的时候,两个线程都走到了判空,下一个指令都是初始化,初始化了两个实例,最终就会导致有一个实例被覆盖掉,如果里面包含的是用户信息就会导致用户数据丢失的情况。

原子性

原子性是并发程序正确执行的三大必要特性之一,其他两个是可见性和有序性。

如果某个操作有多个线程要执行,那么在B执行之前,A的操作要么全部执行完,要么还没开始,B不能再A的操作过程中对数据进行操作。那么这个操作就被称为是原子的。

count++本身操作不是原子的,但是通过synchronized修饰符可以让操作变成原子的,我认为 操作原子化 这个修饰比较贴切。

原子操作是原子的,但是原子操作的组合不是原子的。

如Vector.contains()方法和Vector.add()方法都是原子的,但是二者组合起来先判断是否包含,再添加对象,就是一个典型的检查——执行的竞态现象。

并发中的原子性和事务中的原子性相似,事务中的原子性是一系列数据库操作要么都完成要么还没开始,不能在操作过程中有其他数据修改。

synchronized关键字

具体可以看博文  https://blog.csdn.net/a397525088/article/details/82317338

锁所包含的代码一定是原子操作,一个线程在执行synchronized代码块的时候其他线程是一定不能进入这个代码块的。

synchronized锁被称为内置锁或者监视器锁。

内置锁是一个互斥锁,同一时间点只能有一个线程持有这个锁,除了持有锁的线程外其他线程不可以执行内置锁包含的代码。获取内置锁的唯一途径是执行锁中的代码。

不当的使用可能会导致性能问题。

可重入锁

可重入锁是指一个线程获取到锁后,在释放前仍可以再获取同样的锁。可重入锁的粒度是线程而不是调用,pthread的粒度是调用。

锁的一种实现方法是为锁设置两个参数,当前占用个数0或1,当前线程,如果当前占用为0就是已释放,任何线程都可以获取这个锁,如果当前占用是1,那么就比对来请求这个锁的是否是记录的当前线程,如果是,则可以执行。

可重入锁的设计如果某个线程获取了某个对象的锁,那么在他释放之前他一定可以无限次的获取当前这个锁。

不可重入锁

不可重入锁与可重入锁相对,假设某对象obj的两个方法methodA和methodB是加锁的,methodA中调用了methodB。

那么在某线程执行methodA时,他将无法执行methodB,methodA方法也无法执行完成,造成死锁。

因为不可重入锁记录的是调用,他只记录了当前锁是否被占用,当线程调用methodA时,这个对象的锁被设置为了占用,此时再去执行methodB时,锁判断当前的状态是占用的,所以其他线程都无法进来,导致methodB无法执行,methodA也就无法执行完毕。

最终造成死锁。

其他线程也无法进入A,因为这个对象的锁是占用的。

servlet不是线程安全的,他的service方法没有内置锁,但是servlet设计的初衷就是可以多个线程执行
原子变量和原子操作不建议一起使用,容易造成混乱和性能问题,如要将一个servlet设置成线程安全的,可以选择使用线程安全的类,也可以选择在servlet中需要同步的地方加上锁。
执行时间长的代码不要持有锁,如网络IO,jdbc等

指令重排

指令重排是指在单线程环境中,多个指令顺序调换并不影响最终的结果。

如int a = 1;

int b = 2;

这两条执行先后顺序暂时看来不影响最终结果。先初始化a和先初始化b没有影响。

但是如果放入到多线程环境中,如下代码,如果对number=42和ready=true进行重排序,先执行ready=true,且这个时候还没有对number进行赋值,则代码会进入到打印number,打印0。

(实际上在8G i5的环境下,好不容易复现了还不知道是不是复现的对的)

public class MyThread{
    private static boolean ready = false;
    private static int number = 0;
    private static class ReaderThread extends Thread{
        @Override
        public void run() {
            while(!ready) {
                Thread.yield();
            }
            System.out.println(number);;
        }
    }
    
    public static void main(String[] args) throws InterruptedException {
        new ReaderThread().start();
        number = 42;
        ready = true;
    }
}

64位数据的操作和最低安全条件

java中如果是对64位基本数据(long和double)进行操作的话,如果没有加volatile修饰,在多线程环境中可能会造成读取数据错乱的现象。

所谓最低安全条件是指:即使在多线程环境中读取数据出错了,也是之前数据存放的有意义的曾经的值,这个是多线程中的最低安全条件。

64位数据操作不满足这个条件,因为在java中64位数据的操作是可以分解为两个对32数据操作的结果之和的,很可能读到的值是两个线程操作的两部分值的和。所以除非用volatile或者同步锁,否则64位的共享数据不满足最低安全条件。

volatile变量
volatile修饰的变量在编译和运行都不会被重排序
volatile修饰的变量不会别缓存在寄存器中,因此必须从主存中读取,修改也是直接修改主存的值
操作volatile变量不会加锁,所以相对synchronized是一个轻量级的锁
volatile控制的是可见性,不能控制原子性,volatile修饰的count的count++操作仍然是非线程安全的,即某一时刻读取到的count一定是主存中的,但是读取修改写入操作中的后两步依然是基于第一步读取到的数据的

加锁机制既可以保证原子性和可见性,但是volatile只能保证可见性,通常用作某个操作完成、发生或者中断的标志

发布 逸出

发布一个对象是指使这个对象可以在作用域以外的地方使用。如将对象申明成一个public static对象,或者在一个非私有方法中返回这个对象的引用,或者将对象的引用交给其他类的方法。发布某个对象的某个部分也是发布这个对象。如一个List<Obj> list ;如果修改其中任一个obj,那么也是发布了这个对象,因为你修改了obj就是修改了这个list

逸出就是发布不该发布的对象。

不要在构造函数的过程中发布对象的this引用,如启动一个线程,线程中引用了对象的this引用,此时对象还没有构造完成。可以在构造函数中定义线程,但是不要start。可以定义工厂方法,将构造方法私有。

总结:

对象分配在堆中,变量里保存了对对象的引用。如果某个局部变量的对象引用通过方法传递或者返回给其他地方,则是将引用交了出去,其他地方获取了这个引用。就是发布。

不该发布的时候发布了,就是逸出。

线程封闭

当访问共享的可变数据时,通常情况下,是使用同步。但是如果将数据设置为不共享数据,只在某个线程内访问,就不需要同步。(又是一个和定义冲突的解决方案,书上这么写的,是不是翻译问题,呕)

这种将数据设置为仅单线程内可访问的计数被称为线程封闭。将数据封闭在某个线程里,这个数据仅对这个线程可见。线程封闭的对象本身可以不是线程安全的。如虽然我的数据读取修改写入不是同步的,但是我这个数据同一时刻只有某个线程可见,那么这个读取修改写入操作也具备线程安全性。

典型的有jdbc连接池,连接池在每次需要使用时去获取一个连接,用完之后再返回,在返回之前其他线程看不到这个链接,所以这个连接对象是线程封闭的。(大多数请求如servlet执行过程中是同步的(启动过程不是,是服务器多线程启动多个线程用同一个 servlet去执行的,但是servlet内部是同步的),即不会在执行过程中去启动另一个线程,并将连接发布到这个线程中,所以connection可以认为是线程封闭的)

局部变量和ThreadLocal类也是线程封闭的

Ad-hoc线程封闭

维护线程封闭完全由程序来实现。

栈封闭

栈封闭中只能通过局部变量才能访问对象(局部变量是局部变量表中的变量,包括方法参数和方法中声明的对象,this在局部变量表中也有,而且是第一个,但是应该不是这里说的局部变量)。

如果局部变量是一个基本类型,那么这个变量一定是栈封闭的,因为在java中基本类型只能传值(对象名义是按引用传值,但是实际上也是按值传递,因为对象本身存的就是引用地址的值,而传值就是将他本身包含的引用地址的值传过去),所以基本变量的局部变量一定是栈封闭的。

ThreadLocal封闭

ThradLocal确保每个线程中获取到的值和其他线程是相互隔离的。

不变性

不可变的对象一定是线程安全的。

如果对象创建后状态就不可更改,所有域都是final类型,对象创建过程中没有逸出,那么这个对象就是不可变的。

除非一个对象的某个域需要公开,否则就应该是private的,同理,除非某个对象是需要改变你的,否则也应该是final的,这是一个编程习惯。

监视器模式

监视器模式是把对象的所有可变状态都封装起来,并且用状态对象自己的锁去保护(状态对象的,不是监视器对象的,如果状态是基本数据类型,那么就要使用监视器对象)。

public class PrivateLock{

    private  LockObj lockObj; // lockObj是私有对象,但是这个对象被方法封装,且有内置锁保护

    public void func(){

        synchronized(lockObj){ // 这里锁的lokObj

            ...

        }

    }

}

线程安全性委托
线程安全性委托是指一个类是由多个状态变量组成的,这个类将自己的安全性委托给自己所包含的状态变量。
但是组件安全不意味着类就安全。有时候可能要给其中多个变量的操作再套上一个安全层。
如某个监听器类,包含鼠标监听器和键盘监听器,由于鼠标监听和键盘监听没有直接关联,所以监听器类可以将线程安全性委托给这两个组件。
举一个多个状态变量之间有关联关系的例子。
一个类有两个状态变量,分别是minSize,一个是maxSize,前者必须小于后者。此时即便两个变量是线程安全的,但是组合起来使用,就可能产生线程安全问题,如在设置最小值时还没有读取到最大值所以通过了,但是还没有设值,在设置最大值时没有读取到最小值,也走到了设值的前一步,所以最终导致两者关联关系失效。此时就需要加锁来完成线程安全。
一种特别的构造函数
设有个类,他的功能是包装某个map,构造函数的参数是外部的map对象,即 
public Obj(Map map){};
这里对map做的操作不是赋值,而是将map做一个深拷贝,拷贝的对象赋值到这个对象obj的成员域中。这样外部对map的修改不会影响到obj
另外方法unmodifiablemap可以生成不可修改的map对象

一个加错锁导致的线程安全问题
public class Obj{
    public List<String> list = Collections.synchronizedList(new ArrayList<E>);
    public synchronized void modifyList(E x){
       boolean absent = !list.contains(x);
       if(absent){
            list.add(x);
       }
       return absent;
    }
}
这个锁虽然在这个方法上加了锁,并且list也是线程安全的对象,但是锁是加载obj对象上的,假设此时判断是否包含是不包含,执行完后释放list锁,后面其他地方获取到list锁对list做修改,当前obj的锁并不能阻止他,这里的list是public的,外界很容易获取,其他形式的get方法和这种情况同理
实际上应该加如下锁
public class Obj{
    public List<String> list = Collections.synchronizedList(new ArrayList<E>);;
    public void modifyList(){
        synchronized(list){
            boolean absent = !list.contains(x);
            if(absent){
                list.add(x);
            }
            return absent;
        }
    }
}

通过组合添加原子操作

public class ImprovedList{
    private List<String> list = Collections.synchronizedList(new ArrayList<E>);;

    public ImprovedList(List list){ this.list = list};

    public synchronized void clear(){

        .....

    }

   
    public synchronized void modifyList(){
          boolean absent = !list.contains(x);
          if(absent){
              list.add(x);
          }
          return absent;
    }

    //  其他list方法一样

}

 由于这里的list并没有对外开放获取,所有的操作必须通过improvedList去操作,所以只要保证了improvedList是线程安全的,那么底层的list也一定是线程安全的

客户端加锁

如某个类Obj的get和set方法是原子的,但是组合起来不是原子的,通过在调用端(客户端)给get和set一起加锁,保证操作原子性就是客户端加锁

同步容器类和并发容器类
二者都是线程安全的,但是有时候需要客户端加锁。如vector就是同步容器类。
同步容器的坏处是同步锁太多,严重影响性能。如concurrentHashMap就是并发容器,用于替代同步的散列map,CopyOnWriteList用于代替同步的list,并发容器可以极大提高伸缩性并降低风险。
ConcurrentLinkedQueue先进先出队列,PriorityQueue非并发队列但是可以设置优先级
BlockingQueue扩展了queue,增加了可阻塞的插入和获取操作,如果队列为空,那么获取元素的操作一直阻塞,直到有可用的数据,如果元素满了,那么插入的操作一直阻塞,直到有位置。
队列分为有限长度的和无限长度的。无限长度的队列永远不会插入阻塞。

阻塞队列和生产者消费者模式
生产者消费者模式是两端代码,A端负责生产任务,B端负责处理任务,通过可阻塞队列可以简化代码。如A端生产了任务,防止到阻塞队列中,B端通过轮询去获取,获取到之后处理。阻塞队列如果是优先队列的话可以限制任务处理个数,让待处理任务不要太多导致处理不完。
阻塞队列提供了一个offer方法,offer方法也可以插入数据,但是如果插入失败的话会返回false,这样可以根据返回结果去执行处理策略,如将代办项写入磁盘,或者抑制生产者线程。

queue有add remove方法是非阻塞的

put和take是可以阻塞的

offer有返回状态

// 生产者类

public class Produce implements Runnable{
    public void run() {
        try {
            while(true) {
                if(MainFunc.queue.offer(String.valueOf(MainFunc.queue.size()+1))) {
                    Thread.sleep(1000);
                    System.out.println(new Date().toString()+"新的任务已添加,现在还有"+MainFunc.queue.size()+"个任务");
                }else {
                    Thread.sleep(1000);
                    System.out.println(new Date().toString()+"插入失败,现在还有"+MainFunc.queue.size()+"个任务");
                }
            }
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }

}

//消费者类

public class Consume implements Runnable{
    public void run() {
        try {
            while(true) {
                String task = MainFunc.queue.take();
                Thread.sleep(2000);
                System.out.println(new Date().toString()+"__"+task+"任务已处理,还剩"+MainFunc.queue.size());
            }
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

// 主类
public class MainFunc {
    public static BlockingQueue<String> queue = new LinkedBlockingQueue<>(5);
    public static void main(String[] args) {
        Consume consume = new Consume();
        Produce produce = new Produce();
        new Thread(consume).start();
        new Thread(produce).start();
    }
}

// 执行后台

Tue Sep 11 16:43:18 CST 2018新的任务已添加,现在还有0个任务
Tue Sep 11 16:43:19 CST 2018__1任务已处理,还剩1
Tue Sep 11 16:43:19 CST 2018新的任务已添加,现在还有0个任务
Tue Sep 11 16:43:20 CST 2018新的任务已添加,现在还有1个任务
Tue Sep 11 16:43:21 CST 2018__1任务已处理,还剩2
Tue Sep 11 16:43:21 CST 2018新的任务已添加,现在还有1个任务
Tue Sep 11 16:43:22 CST 2018新的任务已添加,现在还有2个任务
Tue Sep 11 16:43:23 CST 2018__1任务已处理,还剩3
Tue Sep 11 16:43:23 CST 2018新的任务已添加,现在还有2个任务
Tue Sep 11 16:43:24 CST 2018新的任务已添加,现在还有3个任务
Tue Sep 11 16:43:25 CST 2018__2任务已处理,还剩4
Tue Sep 11 16:43:25 CST 2018新的任务已添加,现在还有3个任务
Tue Sep 11 16:43:26 CST 2018新的任务已添加,现在还有4个任务
Tue Sep 11 16:43:27 CST 2018__2任务已处理,还剩5
Tue Sep 11 16:43:27 CST 2018新的任务已添加,现在还有4个任务
Tue Sep 11 16:43:28 CST 2018新的任务已添加,现在还有5个任务
Tue Sep 11 16:43:29 CST 2018__3任务已处理,还剩5
Tue Sep 11 16:43:29 CST 2018插入失败,现在还有4个任务
Tue Sep 11 16:43:30 CST 2018新的任务已添加,现在还有5个任务
Tue Sep 11 16:43:31 CST 2018__3任务已处理,还剩5
Tue Sep 11 16:43:31 CST 2018插入失败,现在还有4个任务

双端队列与工作密取

Deque是一个双端队列,可以在列头和列尾进行插入和移除。

阻塞队列适用于生产者消费者模式。

双端队列则适用于工作密取。工作密取中每个消费者都有各自的双端队列,如果一个消费者完成了自己的消费队列,那么他可以去另一个双端队列的列尾去取任务执行,而原来的消费者是从队头取数据。

这样可以保证所有的消费者线程都在运行,且当自己队列执行完后执行其他消费者队列时减少大量竞争。

阻塞和中断

阻塞是等待外部其他动作完成,是否能继续进行下去由外部事件决定,如等待IO结果,等待锁可用或者等待某项计算完成。

阻塞是一类方法,这些需要等待外部事件来决定是否继续执行下去的方法叫阻塞方法,而中断方法是阻塞方法独有的。

线程在调用阻塞方法且还没有完成的时候称为阻塞状态

中断方法可以选择在阻塞方法等待结果时去做一些操作,这个操作需要开发人员自己去定义。而阻塞方法在接收到中断通知后就会抛出中断异常,开发人员可以决定后面执行的操作。

所以诸如sleep的方法还有阻塞队列的put方法和take方法都会抛出阻塞异常。await()方法也是阻塞方法

下面是一个阻塞方法被中断的例子。

这是中断的常规使用方式,另外线程中还有判断是否中断的方法,如isInterrupt(),和interrupted()方法,通常中断异常的处理方式就是直接抛出给上一层,或者简单处理后再继续抛出

同步工具类

同步工具类是可以根据自身的状态来协调线程的控制流的类。

如阻塞队列,可以根据put take是否阻塞来协调是否继续执行下去。

闭锁:根据是否到达结束状态(如计数器变为0)来控制当前线程是否继续执行下去

其他还有信号量和栅栏

闭锁 latch

闭锁是这样一种锁,他必须等待某些事务执行完毕才会执行。

常见的应用有如下:

确保某个计算所需要的资源都执行完毕

确保依赖的服务都启动完毕

如下代码,假设查询结果依赖于其他两个查询

public class Demo {
    private int a,b;
    public int getA() {
        return a;
    }
    public void setA(int a) {
        this.a = a;
    }
    public int getB() {
        return b;
    }
    public void setB(int b) {
        this.b = b;
    }
    public static void main(String[] args) {
        System.out.println("这是一个需要到数据库里查询两条大数据量然后整合的程序");
        CountDownLatch countDownLatch = new CountDownLatch(2);
        Demo demo = new Demo();
        Thread thread1 = new Thread(()->{
            try {
                System.out.println(new Date().toString()+ "————线程1去数据库里查询A数据");
                Thread.sleep(3000);
                System.out.println(new Date().toString()+ "————过了三秒后线程1的数据查询完毕,a赋值3");
                demo.setA(3);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            countDownLatch.countDown();
        });
        Thread thread2 = new Thread(()->{
            try {
                System.out.println(new Date().toString()+"————线程2去数据库里查询B数据");
                Thread.sleep(5000);
                System.out.println(new Date().toString()+"————过了五秒线程2的数据查询完毕,a赋值5");
                demo.setB(5);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            countDownLatch.countDown();
        });
        Thread thread3 = new Thread(()->{
            System.out.println(new Date().toString()+"————线程3开始,然后等待线程1和线程2的结果执行完");
            try {
                countDownLatch.await();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println(new Date().toString()+"等待完毕,获取A和B的和"+(demo.getA()+demo.getB()));
        });
        thread1.start();
        thread2.start();
        thread3.start();
    }
}

执行结果如下

这是一个需要到数据库里查询两条大数据量然后整合的程序
Wed Sep 12 10:25:55 CST 2018————线程3开始,然后等待线程1和线程2的结果执行完
Wed Sep 12 10:25:55 CST 2018————线程1去数据库里查询A数据
Wed Sep 12 10:25:55 CST 2018————线程2去数据库里查询B数据
Wed Sep 12 10:25:58 CST 2018————过了三秒后线程1的数据查询完毕,a赋值3
Wed Sep 12 10:26:00 CST 2018————过了五秒线程2的数据查询完毕,a赋值5
Wed Sep 12 10:26:00 CST 2018等待完毕,获取A和B的和8

另一种代码一起开始并且等待线程是主线程的demo
public class Demo2 {
    public static void main(String[] args) throws InterruptedException {
        CountDownLatch startGate = new CountDownLatch(1);
        CountDownLatch endGate = new CountDownLatch(3);
        for(int i =0; i<3; i++) {
            Thread thread = new Thread(()->{
                try {
                    startGate.await();
                    System.out.println(Thread.currentThread().getName()+"任务开始");
                    Thread.sleep(3000);// 执行对应任务
                } catch (InterruptedException e) {
                    e.printStackTrace();
                } finally {
                    System.out.println(Thread.currentThread().getName()+"任务结束");
                    endGate.countDown();
                }
            });
            thread.start();
        }
        startGate.countDown(); //  这样可以保证多个线程是一起开始的
        endGate.await();  // 主线程等待其他线程执行完成
        System.out.println(333);
        
    }
}

Thread-0任务开始
Thread-1任务开始
Thread-2任务开始
Thread-1任务结束
Thread-0任务结束
Thread-2任务结束
333

FutureTask
也可以用作闭锁,实现了Callable接口,相对于runable接口他可以有返回结果,而runable是没有返回结果的,另外future还有一些状态。当新建一个future时需要将需要执行的代码传入进去,相当于新建一个线程时传入run方法。而获取结果通过get方法去获取。
代码如下
public class FutureTaskDemo {
    private final FutureTask<String> future = new FutureTask<>(
        new Callable<String>() {
            public String call() throws Exception {
                Thread.sleep(3000);
                return "333";
            };
        }
    );  //  这里新建了一个future类,这个类中放了call函数,相当于新建线程时传入的run方法
    private final Thread thread = new Thread(future); // 将future作为参数传给一个线程,future既继承了callable也继承了runable
    public void start() {
        thread.start();
    }
    public String get() throws InterruptedException, ExecutionException {
        return future.get();
    }
    public static void main(String[] args) throws InterruptedException, ExecutionException {
        System.out.println(new Date());
        FutureTaskDemo demo = new FutureTaskDemo();
        demo.start();  //  启动线程
        System.out.println(demo.get());  // 获取结果
        System.out.println(demo.get());  // 获取结果 一旦计算完了后面就不需要计算了
        System.out.println(demo.get());  // 获取结果 一个future中存储一个计算结果
        System.out.println(demo.get());  // 获取结果 计算完成之前获取方法是阻塞的
        System.out.println(demo.get());  // 获取结果 完成之后就用于存储值
        System.out.println(demo.get());  // 获取结果 但是代价是不是太大了毕竟一个对象挺大的
        System.out.println(new Date());
    }
}
结果如下
Wed Sep 12 15:30:58 CST 2018
333
333
333
333
333
333
Wed Sep 12 15:31:01 CST 2018

信号量 Semaphore
基于之前学习的两个闭锁类,我发现核心方法都是阻塞方法
如CountDownLatch,核心方法是await方法,是阻塞方法,countDown当然也是核心方法
如上面的FutureTask类,核心方法是get方法,也是阻塞方法,用于获取结果,另外一个核心就是设置内部callable方法
信号量里的方法也有很多,但是在我看来核心方法就是acquire()(获取许可,阻塞方法)和release()(释放许可,但是非阻塞方法)
我觉得信号量的作用就是限定同一时间最多有多少个任务可以并发执行
代码如下:
public class SemaphoreDemo {
    private Semaphore semaphore = new Semaphore(3);
    public void acquire() throws InterruptedException {
        semaphore.acquire();
    }
    public void release() {
        semaphore.release();
    }
    public int availablePermits() {
        return semaphore.availablePermits();
    }
    public static void main(String[] args) throws InterruptedException {
        SemaphoreDemo demo = new SemaphoreDemo();
        for(;;) {
            Thread thread = new Thread(()->{
                try {
                    synchronized (demo) {
                        demo.acquire();
                        System.out.println(Thread.currentThread().getName()+"获取到了许可,现在还有"+demo.availablePermits()+"个许可");
                    }
                        Thread.sleep(5000);
                    synchronized (demo) {
                        demo.release();
                        System.out.println(Thread.currentThread().getName()+"释放了许可,现在还有"+demo.availablePermits()+"个许可");
                    }
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            });
            thread.start();
            Thread.sleep(2000);
        }
    }
}    
结果如下:由于时间差,所以会经常在获取请求那里阻塞,到后期通常只剩余1个许可
Thread-0获取到了许可,现在还有2个许可
Thread-1获取到了许可,现在还有1个许可
Thread-2获取到了许可,现在还有0个许可
Thread-0释放了许可,现在还有1个许可
Thread-3获取到了许可,现在还有0个许可
Thread-1释放了许可,现在还有1个许可
Thread-4获取到了许可,现在还有0个许可
Thread-2释放了许可,现在还有1个许可
Thread-5获取到了许可,现在还有0个许可
Thread-3释放了许可,现在还有1个许可
Thread-6获取到了许可,现在还有0个许可
Thread-4释放了许可,现在还有1个许可

阻塞方法原理实现 这边要看留存的疑虑太多
所有的阻塞似乎都和await有关
所有await都和无限循环等待释放条件有关

 

 

任务执行

假设我们要写一个服务器,服务器本质是socket通信,通过不断获取请求socket对IO流进行处理,现在从多线程的角度来分析服务器

串行或并行

每接受到一个客户端请求,我们可以选择使用串行的方式,执行完一个再执行下一个。这个显然效率低下,CPU经常空闲用于等待阻塞方法完成,如IO等。

并行就是每次接受到一个请求都为他分配一个线程去执行(无限制)。这个有如下问题

需要重复的创建线程销毁线程,开销比较高。尤其是只需要执行简单任务时,创建销毁线程的代价显的更大。

资源消耗:假设CPU当前能处理的线程数为10个,但是为了处理任务一次性来了100个任务,我们就相应的创建了一百个线程,其中就是个线程会长期处于空闲状态,占用过多内存,且争夺CPU也有性能开销

稳定性:受服务器配置限制,过多的线程数量可能导致内存溢出。 

 

Executor框架

Executor是一个基于生产者消费者模式的框架,如果要实现生产者消费者模式,最简单的方式就是用Executor框架。代码如下

public class ExecutorDemo {
    private static final int NTHREADS = 100;
    private static final Executor executor = Executors.newFixedThreadPool(NTHREADS);
    @SuppressWarnings("resource")
    public static void main(String[] args) throws IOException {
        ServerSocket socket = new ServerSocket(8080);
        for(;;) {
            final Socket connection = socket.accept();
            Runnable task = ()->{
                handleRequest(connection);
            };
            executor.execute(task);
        }
    }
    public static void handleRequest(Socket connection) {
        System.out.println("处理一个请求");
        String string;
        try {
            BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(connection.getInputStream()));
            string = bufferedReader.readLine();
            while(string!=null) {
                System.out.println(string);
                string = bufferedReader.readLine();
            }
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

 

请求localhost:8080/123时打印如下

处理一个请求
处理一个请求
GET /smartPlat/hello HTTP/1.1
Host: localhost:8080
Connection: keep-alive
Cache-Control: max-age=0
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/68.0.3440.106 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8
Accept-Encoding: gzip, deflate, br
Accept-Language: zh-CN,zh;q=0.9,zh-TW;q=0.8
 

要说的是这里用了接口特性,如果要修改executor执行模式,只需要在域声明时修改实现类即可,后面说一下接口的两个作用

1方便使用的开发人员,开发人员在调用接口时一旦需要改其他使用方式只需要修改声明时的类即可,如获取一个map,本来对顺序不做要求,后续照常处理,突然需求变更要求有序,只需要在对象声明时将实现类改为linkedMap即可,这里executor框架也是一样,一旦想换成其他执行模式只需要改声明即可

2提供调用规范,对于使用的开发人员是便利,对于开发的开发人员就是规范,实现规范是为了告诉上层调用者具体实现方法是这么实现的,如线程的runable接口,实现就是为了告诉线程你跑的时候跑这个方法,调度器里的调度方法就是告诉调度器你时间到了执行我这个方法就可以

 

框架是生产者消费者模式,他将任务提交和任务执行解耦开,可以很轻松的修改任务执行策略。
框架提供了多种实现,甚至开发人员可以自行实现,可以将executor的方法写为一个线程一个任务的,或者串行执行的
看到Thread thread = new Thread(Runable)的时候尽量考试能不能使用Executor框架

执行策略
在执行策略(executer方法)中可以定义多种问题解决方案,如
在什么线程中执行
执行顺序是什么(先进先出,后进先出还是优先级)
最多可以有多少个线程并发执行
如果过载要拒绝任务按照什么策略去确定拒绝哪个任务
任务执行前后要执行什么操作
线程池
线程池中的线程不释放资源,也不需要重复创建资源,每次有任务需要执行的时候分配线程,分配出去的线程不可再用,直到该线程任务执行完毕返回到线程池,有任务到达时再分配出去
好处有:
不需要重复创建销毁资源
线程池数量根据平台而定,不会出现线程数量太多而导致的大量线程空闲,争夺cpu消耗性能等问题

线程池的一些类库
这些方法都来自类Executors,里面提供了大量的静态方法用于返回不同策略的执行器,属于典型的静态工厂
newFixedThreadPool:建立一个有固定长度的线程池,每有一个新的任务进来就创建一个线程,直到到达最大数量不再改变。如果有线程抛异常结束,会补充新的线程,这里是书上的简介,方法的注释上有这段话:
If we cannot queue task, then we try to add a new thread.
我认为这段注释表示任务添加时会先判断是否有线程可以去执行,而不是每个新任务都创建线程

 

newCachedThreadPool:有缓存的线程池,当前线程数量大于任务数时,会有部分线程空闲,执行器会回收这些空闲线程,线程池没有大小限制
newSingleThreadExecutor:单线程执行器,串行执行
newScheduledThreadPool:创建固定长度线程池以延迟或定时的方式执行

关闭执行器
在jvm中只有当所有非守护线程都关闭后jvm才会关闭(守护线程会在所有非守护线程关闭后自动关闭),而executor是非守护线程,所以只要executor不关闭那么jvm用于不关闭。
关闭执行器有两种方法,一种是平缓关闭一种是暴力关闭,暴力关闭就是不管三七二十一所有的都关了,也不管你是否在执行,不等你执行完直接结束。
平缓关闭就是等待当前所有任务执行完毕再关闭,而且过程中不会接受新的任务。
Executor接口的扩展接口ExecutorService(或者说ExecutorService继承了接口Executor),规定了关闭方法,如shutDown平缓关闭,shutDownNow暴力关闭,isShutdown是否关闭,isTerminated是否结束可以用于轮询。
isshutdown是是否开始关闭
isterminated是是否关闭完成
大多数执行器也实现了ExecutorService接口。
第一个版本的服务器是通过while(true)去不断循环获取socket请求,改版后的可以通过while(exec.isShutDown)来获取请求,一旦关闭后就不会再接收新的请求
而如果想关闭服务器可以开发一个stop方法,执行的内容就是exec.shutdown(),可以根据请求信息来判断是否是关闭请求
Timer和newScheduledThreadPool
Timer执行任务时只创建一个线程,如果创建了两个任务,一个任务10ms执行一次,一个40ms执行一次,那么如果限制性的是40ms的,10ms的任务执行就会有问题。而一旦某个任务抛出异常,整个线程都会停止,其他任务也不会执行。
如果构建自己的调度服务可以使用DealyQueue

取消与关闭

任务取消和关闭不可以直接用stop,会导致出现不一致的情况,原来的操作到一半突然截断。java提供了中断是一种协作机制,在如果方法愿意停下的话就会停下。

任务取消的几种情况

1、用户点击取消按钮,需要取消之前进行中的任务

2、任务有时间限制,执行时间超时了需要取消

3、应用程序事件取消:本来是搜索多个区域通过多线程去搜索,如果某个区域找了结果,结果只有一个的话,其他线程就需要取消。

4、执行时发生错误或者异常:如写入文件但是磁盘满了

5、关闭程序

任务取消

一个可取消的任务一定要有取消策略,即需要定义怎么取消,什么时候取消,取消什么。

简单的取消机制可以通过volatile变量去取消,由于数据是及时可见的,所以一旦数据变更可以立刻通知到线程,通过while()中的判断条件去取消下一次执行。

但是这种取消有一个缺点,如果某一个方法是阻塞方法,而方法永远阻塞了,那么将永远无法执行到下一次循环判断条件,也就无法取消任务。

如某个有界序列中的put方法。大小已经塞满,此时取消,某个线程执行到put的时候由于阻塞了,永远无法继续执行到下一个循环,也就永远无法取消

使用中断可以解决,中断也是取消的最好的方式

中断

中断是线程的一种状态,他有两个查询方法,一个是实例方法isInterrupted,一个是类方法interruped

实例方法是获取调用实例是否是中断的,而类方法是获取当前线程的中断状态且清除中断状态,两个方法源码如下:

public boolean isInterrupted() {
        return isInterrupted(false);   //实例方法
}

public static boolean interrupted() {
        return currentThread().isInterrupted(true);  // 类方法
}

 

private native boolean isInterrupted(boolean ClearInterrupted);  // 两个方法共同调用的是本地方法,布尔值是是否清楚状态

 

Thread.sleep和Object.wait方法都是阻塞的,在响应中断的时候会清除中断状态,抛出InterruptException

恢复中断状态

目前理解就是通过实例方法去中断,是为了防止中断后调用的是清除状态的中断使中断状态丢失。

如果不知道线程的内部执行整个过程不要去中断线程。

恢复中断的两种方式是传递异常和恢复中断状态。传递异常即继续向上层抛出去,

一定不能屏蔽异常(即在catch代码体内是空的)

服务与线程

同南昌相互之间关系是服务包含线程,如线程池是一个服务,他包含其中管理的线程。

服务的生命周期一般比自己管理的线程生命周期长。

服务或应用程序不应该对自己没有拥有权的线程做操作,如线程池包含十个线程,定时器任务包含另外五个线程,那么线程池没有对定时器拥有的五个线程做操作的权限。

虽然线程池是应用程序(如某个服务器)的一部分,但是应用程序不能绕过线程池对线程池管理的线程直接操作。

所以一般服务要对外提供对应的生命周期方法,如关闭服务(关闭其包含的所有线程)

日志线程

日志线程可以使用生产者消费模式,一端生产日志后不直接写入,而是存储在队列由日志线程去读取在写入。

 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
展开阅读全文

没有更多推荐了,返回首页