Android-并发基础三

1.线程池

1.1为什么需要使用线程池?
  • 降低资源消耗:通过服用已经存在的线程,降低创建线程和销毁线程对资源的消耗。

  • 提高相应速度:任务来了,不需要等待线程的创建,直接使用线程执行任务。

  • 提高线程的可管理性:线程是稀缺资源,如果无限制的创建,不仅会消耗CPU的资源,还会降低系统的稳定性,使用线程池可以统一分配、调优和监控线程。

1.2线程池相关的类分析:

Executor:是一个接口,是Executor架构的基础,是用来将任务的提交和任务的执行进行分离。

ExsecutorService:继承Executor,增加了shutdown()、submit(),可以认为是线程池的真正的接口。

AbstractExecutorService:实现了ExecutorService中的大部分方法。

ThreadPoolExecutor:是线程池的核心类,用来执行被提交的任务。

ScheduledExecutorService:继承了ExecutorService,提供了带周期执行功能的ExecutorService。

ScheduledThreadPoolExecutor:是一个实现类,可以在给定的延迟后运行命令或则定期执行,比Timer灵活,功能强大。

1.3线程池的构造方法:
public ThreadPoolExecutor(int corePoolSize, int maximumPoolSize, long keepAliveTime, TimeUnit unit, BlockingQueue<Runnable> workQueue, ThreadFactory threadFactory, RejectedExecutionHandler handler) {
    
    
    this.ctl = new AtomicInteger(ctlOf(-536870912, 0));
    this.mainLock = new ReentrantLock();
    this.workers = new HashSet();
    this.termination = this.mainLock.newCondition();
    if (corePoolSize >= 0 && maximumPoolSize > 0 && maximumPoolSize >= corePoolSize && keepAliveTime >= 0L) {
    
    
        if (workQueue != null && threadFactory != null && handler != null) {
    
    
            this.corePoolSize = corePoolSize;
            this.maximumPoolSize = maximumPoolSize;
            this.workQueue = workQueue;
            this.keepAliveTime = unit.toNanos(keepAliveTime);
            this.threadFactory = threadFactory;
            this.handler = handler;
        } else {
    
    
            throw new NullPointerException();
        }
    } else {
    
    
        throw new IllegalArgumentException();
    }
}
  • corePoolSize:核心线程数量,当提交一个任务时,线程池会创建一个新的线程执行任务直到线程数量等于核心线程数量。如果再继续提交任务,任务会添加到阻塞队列,并不会再创建新的线程。如果执行prestartAllCoreThreads()这个方法会提前创建并启动所有的核心线程数量。
  • maximumPoolSize:当阻塞队列也满了,再提交任务就会创建新的线程执行,前提是线程的数量小于maximumPoolSize的数量。
  • keepAliveTime:线程空闲的时候,线程的存活时间。默认情况下,只有当线程数量大于核心线程数量才会有效。
  • TimeUnit:keepAliveTime的时间单位。
  • BlockingQueue:必须是BlockingQueue阻塞队列才行。当线程池的数量大于corePoolSize数量,线程会进入阻塞队列进行阻塞等待,这样线程池也实现了阻塞的功能。一般来说,应该使用有界阻塞队列,避免将资源耗尽的风险。
  • ThreadFactory:创建现成的线程工厂,可以统一给线程设置名称等。
  • RejectedExecutionHandler:线程池饱和策略,当阻塞队列满了,切没有空闲线程的时候,如果继续提交任务,必须采取一种策略处理该任务,线程池提供了四种策略:
    • AbortPolicy:直接抛出异常,默认策略。
    • CallerRunsPolicy:使用调用线程来执行这个任务。谁调用谁执行。
    • DiscardOldestPolicy:丢弃队列最靠前的任务,并执行当前任务。
    • DiscardPolicy:直接丢弃任务。
1.4线程池的工作机制
  • 如果当前运行的Thread小于核心线程数量,直接创建新的线程执行任务。
  • 如果线程数量等于corePoolSize,则会将新添加的Tast给添加到阻塞队列中。
  • 如果阻塞队列满了,那提交的任务会创建新的线程执行任务。
  • 当线程数量大于maximumPoolSize,任务将被拒绝,并抛出异常。
1.5线程池任务的提交
  • execute():用于提交不需要返回值的任务,无法判断任务是否执行完毕等。
  • submit():用于提交需要返回值的任务,线程池会返回一个Future对象,通过这个对象可以判断任务是否执行成功,并通过Future的get方法来得到返回值,get方法会阻塞当前线程知道执行完毕为止,也可以阻塞一段时间到时间立即返回,此时任务可能并未执行完毕。
1.6线程池的关闭

可以通过shutdown和shutdownNow来关闭线程池,原理都是遍历线程池中的工作线程,然后逐个调用interrupt来中断线程,所以无法响应中断任务的线程可能永远无法停止。

  • shutdown():只是将线程池的状体置为SHUTDOWN状态,然后终止所以没有在执行任务的线程。
  • shutdownNow():首先将线程池的状态置为Stop,然后尝试停止所有正在执行任务的线程,并返回正在执行任务的列表。

注意只要调用上面的任何一个方法,isShutDown就会返回true,但是只要所有的任务都已经关闭,线程池才会真正关闭。此时调用isTerminated()会返回true,通常调用shutdown来关闭线程池,如果不关心任务是否执行完,也可以调用shutdownNow方法。

1.7合理位置线程池

配置线程池需要分析任务的类型:

  • 任务的性质:CPU密集型还是IO密集型
  • 执行任务的优先级:是否又明确的优先级。
  • 执行任务的时间:任务是否明确可以估计大约的耗时。
  • 执行的任务是否又依赖:如是否需要连接数据库。

CPU密集型应该配置较小的线程数量,一般是Ncpu+1个线程的线程池。

IO密集型线程不一定在执行任务,所以可以多分配线程如:2*Ncpu

如果有任务有优先级,可以使用优先级队列priorityBlockQueue()

2.AQS

队列同步器:AbstractQueuedSynchronizer,是用来构建锁和其他同步器的基础架构,它是用一个int类型的值来表示同步状态,同步内置的FIFO队列来完成资源获取线程的排队工作,设计者期望它称为实现大部分的同步操作的基础。

AQS 的主要使用方式是继承,子类通过继承 AQS 并实现它的抽象方法来管理同步状态,在 AQS 里由一个 int 型的 state 来代表这个状态,在抽象方法的实
现过程中免不了要对同步状态进行更改,这时就需要使用同步器提供的 3 个方法(getState()、setState(int newState)和 compareAndSetState(int expect,int update))来进行操作,因为它们能够保证状态的改变是安全的。

AQS 自身没有实现任何同步接口,它仅仅是定义了若干同步状态获取和释放的方法来供自定义同步组件使用,同步器既可以支持独占式地获取同步状态,也可以支持共享式地获取同步状态,这样就可以方便实现不同类型的同步组件(ReentrantLock、ReentrantReadWriteLock 和 CountDownLatch 等)

AQS 同步器的设计基于模板方法模式。模板方法模式的意图是,定义一个操作中的算法的骨架,而将一些步骤的实现延迟到子类中。模板方法使得子类可以不改
变一个算法的结构即可重定义该算法的某些特定步骤。(定义了一些列方法,并规定了方法的执行顺序)

AQS设置状态操作的方法:

  • getState():获取当前同步状态。
  • setState(int newState):设置当前同步状态。
  • compareAndSetState(int expect,int update):使用 CAS 设置当前状态,该方法能够保证状态设置的原子性。

AQS重需要复写的方法总结

  • tryAcquire():独占式获取同步状态,实现该方法需要查询当前的状态并判断同步状态是否符合预期,然后再进行CAS设置同步状态。
  • tryRelease():独占式释放同步状态,等待获取同步状态的线程将有机会获取同步状态。
  • tryAcquireShare():共享式获取同步状态,只要状态大于0表示获取成功。
  • tryReleaseShared:共享式释放同步状态。

3.CLH队列锁

CLH 队列锁也是一种基于链表的可扩展、高性能、公平的自旋锁,申请线程仅仅在本地变量上自旋,它不断轮询前驱的状态,假设发现前驱释放了锁就结束自旋。

当一个线程需要获取锁时:

  • 创建一个的 QNode,将其中的 locked 设置为 true 表示需要获取锁, myPred表示对其前驱结点的引用。
  • 线程 A 对 tail 域调用 getAndSet 方法,使自己成为队列的尾部,同时获取一个指向其前驱结点的引用 myPred
  • 线程就在前驱结点的 locked 字段上旋转,直到前驱结点释放锁(前驱节点的锁值 locked == false)
  • 当一个线程需要释放锁时,将当前结点的 locked 域设置为 false,同时回收前驱结点

AQS 是 CLH 队列锁的一种变体实现

4.ReentraintLock

ReentraintLock:是一种可重入锁。锁的可重入是指:指任意线程在获取到锁之后能够再次获取该锁而不会被锁所阻塞,包含两层语义:

  • 线程再次获取锁。锁需要去识别获取锁的线程是否为当前占据锁的线程,如果是,则再次成功获取。
  • 锁的最终释放。线程重复 n 次获取了锁,随后在第 n 次释放该锁后,其他线程能够获取到该锁。锁的最终释放要求锁对于获取进行计数自增,计数表示
    当前锁被重复获取的次数,而锁被释放时,计数自减,当计数等于 0 时表示锁已经成功释放。

如果该锁被获取了 n 次,那么前(n-1)次 tryRelease(int releases)方法必须返回false,而只有同步状态完全释放了,才能返回 true。可以看到,该方法将同步状
态是否为 0 作为最终释放的条件,当同步状态为 0 时,将占有线程设置为 null,并返回 true,表示释放成功。

ReentrantLock 的构造函数:

  • 默认的无参构造函数将会把 Sync 对象创建为NonfairSync 对象,这是一个“非公平锁”;

  • 而另一个构造函数ReentrantLock(boolean fair)传入参数为 true 时将会把 Sync 对象创建为“公平锁”FairSync。

5.可见性

​ 可见性是指当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值。

​ 由于线程对变量的所有操作都必须在工作内存中进行,而不能直接读写主内存中的变量,那么对于共享变量 V,它们首先是在自己的工作内存,之后再同步
主内存。可是并不会及时的刷到主存中,而是会有一定时间差。很明显,这个时候线程 A 对变量 V 的操作对于线程 B 而言就不具备可见性了 。

​ 要解决共享对象可见性这个问题,我们可以使用 volatile 关键字或者是加锁

6.原子性

原子性:即一个操作或者多个操作 要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行。

那么线程切换为什么会带来 bug 呢?因为操作系统做任务切换,可以发生在任何一条 CPU 指令执行完!注意,是 CPU 指令,CPU 指令,CPU 指令,而不是高级语言里的一条语句。比如 count++,在 java 里就是一句话,但高级语言里一条语句往往需要多条 CPU 指令完成。其实 count++至少包含了三个 CPU 指令

7.Volatile关键字

volatile是最轻量的同步操作,可以把对 volatile 变量的单个读/写,看成是使用同一个锁对这些单个读/写操作做了同步

volatile 变量自身具有下列特性:

  • 可见性。对一个 volatile 变量的读,总是能看到(任意线程)对这个 volatile变量最后。
  • 原子性:对任意单个 volatile 变量的读/写具有原子性,但类似于 volatile++这种复合操作不具有原子性。

volatile 虽然能保证执行完及时把变量刷到主内存中,但对于 count++这种非原子性、多指令的情况,由于线程切换,线程 A 刚把 count=0 加载到工作内存,
线程 B 就可以开始工作了,这样就会导致线程 A 和 B 执行完的结果都是 1,都写到主内存中,主内存的值还是 1 不是 2。

volatile 的实现原理:

volatile 关键字修饰的变量会存在一个“lock:”的前缀。Lock 前缀, Lock 不是一种内存屏障,但是它能完成类似内存屏障的功能。 Lock
会对 CPU 总线和高速缓存加锁,可以理解为 CPU 指令级的一种锁。

同时该指令会将当前处理器缓存行的数据直接写会到系统内存中,且这个写回内存的操作会使在其他 CPU 里缓存了该地址的数据无效。

8.synchronized实现原理

Synchronized 在 JVM 里的实现都是基于进入和退出 Monitor 对象来实现方法同步和代码块同步,虽然具体实现细节不一样,但是都可以通过成对的MonitorEnter 和 MonitorExit 指令来实现。

对同步块,MonitorEnter 指令插入在同步代码块的开始位置,而 monitorExit指令则插入在方法结束处和异常处,JVM 保证每个 MonitorEnter 必须有对应的
MonitorExit。总的来说,当代码执行到该指令时,将会尝试获取该对象 Monitor的所有权,即尝试获得该对象的锁:

  • 如果 monitor 的进入数为 0,则该线程进入 monitor,然后将进入数设置为 1,该线程即为 monitor 的所有者。
  • 如果线程已经占有该 monitor,只是重新进入,则进入 monitor 的进入数加1
  • 如果其他线程已经占用了 monitor,则该线程进入阻塞状态,直到 monitor的进入数为 0,再重新尝试获取 monitor 的所有权。

对同步方法,从同步方法反编译的结果来看,方法的同步并没有通过指令monitorenter 和 monitorexit 来实现,相对于普通方法,其常量池中多了ACC_SYNCHRONIZED 标示符。

synchronized 使用的锁是存放在 Java 对象头里面, Java 对象的对象头由 mark word 和 klass pointer两部分组成。

  • mark word 存储了同步状态、标识、hashcode、GC 状态等等
  • klass pointer 存储对象的类型指针,该指针指向它的类元数据

9.锁的状态

一共有四种状态,无锁状态,偏向锁状态,轻量级锁状态和重量级锁状态,它会随着竞争情况逐渐升级。锁可以升级但不能降级,目的是为了提高获得锁和
释放锁的效率。

9.1.偏向锁

大多数情况下锁不仅不存在多线程竞争,而且总是由同一线程多次获得,为了让线程获得锁的代价更低而引入了偏向锁,减少不必要的 CAS 操作。

偏向锁,顾名思义,它会偏向于第一个访问锁的线程,如果在运行过程中,同步锁只有一个线程访问,不存在多线程争用的情况,则线程是不需要触发同步
的,减少加锁/解锁的一些 CAS 操作(比如等待队列的一些 CAS 操作),这种情况下,就会给线程加一个偏向锁。 如果在运行过程中,遇到了其他线程抢占
锁,则持有偏向锁的线程会被挂起,JVM 会消除它身上的偏向锁,将锁恢复到标准的轻量级锁。它通过消除资源无竞争情况下的同步原语,进一步提高了程序的
运行性能。

偏向锁只有遇到其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放偏向锁,线程不会主动去释放偏向锁。

9.2.轻量级锁

轻量级锁是由偏向锁升级来的,偏向锁运行在一个线程进入同步块的情况下,当第二个线程加入锁争用的时候,偏向锁就会升级为轻量级锁;

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

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

自旋锁的优缺点
自旋锁尽可能的减少线程的阻塞,这对于锁的竞争不激烈,且占用锁时间非常短的代码块来说性能能大幅度的提升,因为自旋的消耗会小于线程阻塞挂起操
作的消耗! 自旋锁等待的最长时间一般是一个CPU上下文切换的时间。

猜你喜欢

转载自blog.csdn.net/u014078003/article/details/124638274