Java并发编程-多线程-锁与线程安全

线程

进程与线程:

进程独享内存数据,线程共享内存数据。线程依附于进程;

线程与调度:

多核CPU下,线程可以真正并行,CPU数量小于线程数时则需调度;
抢占式调度:OS会剥夺线程运行权,给其他线程执行机会(时间片轮转)
协作式调度:线程只有在 被阻塞和等待时 才会失去运行权;

线程状态:可通过Thread.State#getState()获取

NEW(新建状态):Thread t = new Thread( ()->{} ); 此时t处于新建状态
RUNNABLE(可运行):t.start();    此时t处于可运行状态,依赖OS调度成为运行态或未运行态;
BLOCKED(被阻塞):暂停运行,知道获取到内部锁;
WAITING(等待):暂停运行,等待通知;
TIMED_WAITING(计时等待):暂停运行,等待通知或超时后继续运行;
TERMINATED(终止):自然死亡或异常导致终止;

线程状态转换

Runnable->Blocked: 请求对象锁失败或调用了Thread.sleep();
Blocked->Runnable: 成功获取对象锁;

Runnable->Waiting:对象锁.wait()、otherThread.join()或currentThread.yield()、
    请求java.util.concurrent.Lock或Condition失败;
Waiting->Runnable:对象锁.notify、otherTread执行完毕、获取Lock或Condition成功;

Runnable->Timed_Waiting:Thead.sleep(ms)、otherThread.join(ms)、lock.tryLock()
    和condition.await()计时版;
Timed_Waiting->Runnable:sleep超时到达、join超时到达、获取Lock或Condition成功;

线程中断请求:interrupt();

线程自start()以后,应该不时检查中断信号或currentThread.isInterrupted();
当试图以interrupt()中断一个调用了sleep(),wait(),join()而进入阻塞状态的线程时,该线程
    会抛出一个InterruptedException

线程优先级别:

优先级高度依赖OS调度(低优先级可能会饿死;linux虚拟机下优先级无效);
可以通过setPriority(Thread.NORMAL_PRIORITY)设置[Thread.MIN_PRIORITY,Thread.MAX_PRIORITY]之间值

注意:线程礼让(Thread.yield()时候只会礼让至少比本线程优先级高的线程)

守护线程:

thread.setDaemon(true)会使线程进入守护状态;守护线程不能访问资源,因为如果只剩守护线程,
    虚拟机会退出;

线程异常处理器:

由于run方法不能抛出只能抓捕受检异常;处理器可以抓捕到运行时异常(从而进行一些回滚操作);

默认处理器:Thread.setDefaultUncaughtExceptionHandler(uncaughtExceptionHandler);

特殊处理器:t.setUncaughtExceptionHandler(uncaughtExceptionHandler);

线程默认的处理器是线程组ThreadGroup对象,ThreadGroup同样实现了uncaughtException方法:
    1. 如果有父线程组则调用父线程组的uncaughtException方法;
    2. 否则若Thread.getDefaultUncaughtExceptionHandler不为空则调用之;
    3. 否则若异常为ThreadDeath实例则不处理,否则输出栈轨迹到System.err;

线程局部变量:

每个线程中该变量是独享的(相当一个局部变量,但比局部变量开销更小);
public static final ThreadLocal<SimpleDateFormat> threadDateFormat = ThreadLocal.withInitial(()
    ->new SimpleDateFormat("yyyy-MM-dd"));

线程中使用 threadDateFormat.get()时候,第一次会调用初始化方法并将其保存在ThreadLocalMap中,之后
    本线程的get不再初始化(前提是线程没销毁);
*Ps:由于ThreadLocal变量线程不销毁没法回收,应该注意内存泄漏问题;

锁与同步

锁说明: 同一个锁锁定的代码块可以保证在多线程访问的时候以串行的方式执行;
锁意义: 在于保证共享数据的【可见性,有序性,原子性】;
锁原因: 对于异步的非原子读写操作,可能会使共享内存数据损坏;
锁条件:对于获取到锁却不符合某种条件的线程不该机型执行,此时应该放弃锁并等待条件满足再继续执行;

1. 使用java.util.concurrent.locks.Lock和Condition实现
    a. 共享锁对象和条件:mLock = new Lock(); mCondition = mLock.newCondition();
    b. 加锁和条件检查:
        mLock.lock();
        try{
            while(!(满足业务条件)) {
                mCondition.await(1000, TimeUnit.MILLISECONDS); 
                // 这里使用计时版,不依赖其他线程调mCondition.notifyAll()唤醒,否则可能出现死锁;
                // 进入 await方法后如果收到interrupt请求会抛InterruptedException,
                // 而awaitUninterruptibly()会一直待在等待集且不会受interrupt请求影响;
            }
        }finally {
            mLock.unlock();
        }
    c. 锁测试和超时:线程在获取锁失败时会发生阻塞,直到成功取得锁才继续执行;
        lock方法无法中断,若发生死锁无法终止;
        if(mLock.tryLock()){// 尝试获取锁成功返回true,调用thread.interrupted()中断时抛出异常;
            // 也可以设置尝试获取时间 mLock.tryLock(100,TimeUnit.MILLISECONDS)
            // 或lockInterruptibly()无限尝试
            try{ }finally{ mLock.unlock(); }
        }else { // 做其他处理
        }
    d. 读写锁:可重入读写锁ReentrantReadWriteLock可以获取读锁(共用读排斥写)和写锁(排斥读写操作)
        ReentrantReadWriteLock rwl = new ReentrantReadWriteLock();
        Lock rl = rwl.readLock();
        Lock wl = rwl.writeLock();
        对于并发多读而少写的操作,读写锁会提高效率;

2. 使用synchronized实现锁
    a. synchornized方法默认用当前对象或class对象作为锁,sync块需要显示加锁;对象锁只有一个默认条件;
    b. Object中的wait和notify方法只能在 获取了当前锁的同步方法 中使用;(notify随机唤醒)

*Java加锁方案:
    1. 优先使用java.util.concurrent相关机制(如阻塞队列)
    2. synchornized
    3. Lock/Condition

锁相关:

    非公平锁:线程获取锁的顺序不一定是申请锁的顺序,而是由系统为保证最大吞吐量自由调度。
        可能会导致优先级反转或饥饿现象;
    公平锁:线程获取锁的顺序按照申请锁的顺序;ReentrantLock底层通过AQS实现调度,
        故能在构造时指定为公平锁。synchronized无法构造公平锁;

    可重入锁(递归锁):外层方法获取锁以后进入内层方法可自动获取锁(而不是阻塞成死锁);
        ReentrantLock和synchronized都是可重入锁;

    独享锁(表现为:互斥锁):锁在同一时刻只能被一个线程获取;ReentrantLock和synchronized都是独享锁;
    共享锁(表现为:读写锁):锁在同一时刻可以被多个线程获取;ReadWriteLock读锁通过AQS实现读读共享;

    悲观锁:从这个看待并发的角度认为并发操作一定导致数据修改,必须加锁保证数据的正确;
    乐观锁:从这个看待并发的角度认为并发操作不一定导致数据修改,通常使用CAS算法实现无锁编程
    Ps:使用java.concurrent.atomic.*时可能会导致CAS算法的ABA问题,
        必要时需使用AtomicMarkableReference<T>或AtomicStampedReference<T>

    分段锁:一种锁的设计思想;比如ConcurrentHashMap中以每个Hash桶链表作为锁,
        put操作时对某个段而非全部数据加锁,这样实现了并行插入;

    偏向锁:如果一个synchronized锁一直被同一个线程访问,那么该线程可以自动获取偏向它锁而无需申请;
    轻量级锁:如果一个偏向线程A的锁有了另外一个线程B在申请,此时该锁膨胀为轻量级锁,
        B通过自旋而非阻塞的方式获取这个锁;
    重量级锁:若在轻量级锁的时候,线程B自旋一段时间还没能获取,则B休眠,该锁膨胀为重量级锁,
        再有线程申请通通休眠等待唤醒;

    自旋锁:线程在获取锁的时候不立即阻塞而是通过循环不断去尝试,从而减少线程上下文切换的消耗。
        但这一行为会消耗cpu,应该在尝试一段时间后进入休眠状态;

volatile域:

保证在多cpu条件下,保证变量的可见性和有序性但不能保证原子性,加锁既能保证原子性也能保证可见性;
    可见性:每个线程有自己的内存缓存,其他线程缓存在本线程中不可见;(Java通过volatile保证可见性)
    原子性:对于非单一的指令,要么都执行,要么都不执行;
        (Java内存模型只保证基本数据类型的读取和赋值是原子操作,
        java.util.concurrent.atomic包通过高级机器指令而不是锁来保证了原子性)
    有序性:对于编译优化允许编译器和处理器对指令进行重排序,只保证重排序的结果和没排序的结果相同;
        (Java虚拟机的有序性遵循happens-before原则)

Ps:volatile机制:相当于一个内存栅栏,指令重排序不可通过有volatile存在的语句,
    因为其在写操作后立即刷新主缓存并标记其他缓存无效;

final域

一个final变量只有在初始化指令都完成以后才对线程可见并不可修改内存数据,保证了内存的正确性;

原子类型:java.concurrent.atomic.*;

AtomicInteger x = new AtomicInteger(); // 这个初始化对于下面语句都有效

1. x.incrementAndGet();// 保证这个方法执行的原子性(相当于这个方法有锁),
    // 等价于synchronized (""){ x.set(x.addAndGet(1)); }
2. for(int i=0;i<n;i++)
    do{
        val = x.get();
        t = num[i];// 假设每个线程中num不一样
        t = Math.max(t,val);
    }while(!x.compareAndSet(val,t));
// 编译器可以通过旧值val去判断x是否失效,从而返回false在此自旋,从而保证x是所有线程中i的最大值;
// 这个循环等价lambda:x.updateAndGet(x->Math.max(x,num[i]))或accumulateAndGet(num[i],Math::max);
注:原子类型不局限于基本类型,前缀Atomic还有Reference,ReferenceArray,ReferenceFieldUpdater;

Ps: 这样的乐观自旋在大量线程竞争情况下效率会大幅下降;

阻塞队列:java.util.concurrent.BlockingQueue和BlockingDeQueue

阻塞队列把线程分两个角色:生产者和消费者;
    生产者往队列中添加元素(若队列满则阻塞);
    消费者从队列取出元素(若队列空则阻塞);
阻塞队列能保证:添加和取出操作的原子性,一个元素只能被一个消费者取出,生产和消费在阻塞中负载均衡;

Ps: 由于消费者线程在取出操作可能阻塞,需要手动定义一个结束标志元素销毁消费线程;

BlockingQueue<E>: 单向队列接口;ArrayBlockingQueue<E>循环队列, LinkedBlockingQueue<E>链表
    #put(E): 生产者添加元素;必要时阻塞
    #offer(E,delay,timeUnit): 在delay单元的延时内添加E元素,超时返回false,添加成功返回true;
    #take(): 消费者取出队头元素;必要时阻塞
    #poll(delay,timeUnit): 在delay单元的延时内取出队头元素,超时返回null;(故不能添加null元素)

BlockingDeque<E>: 双端队列接口;LinkedBlockingDeque<E>
    #putFirt/putLast(E): 添加头尾元素,必要时阻塞;
    #offerFirst/offerLast(E,delay,timeUnit): 一定延时内添加元素,添加成功true,超时返回false
    #takeFirst/takeLast(): 取出头尾元素,必要时阻塞;
    #pollFirst/pollLast(E,delay,timeUnit): 一定延时内取出元素,超时返回null,成功返回元素;

*此外还有基于堆实现的优先阻塞队列:PriorityBlockingQueue<E>,
    要求元素实现Comparable或提供Comparator比较器用于判断优先级;

竞争分量:

思想: 每个线程分配一个分量(保证分量ai=ai#x的原子性),计算完毕后合并分量;
意义: 解决大量线程竞争问题;

LongAdder adder = new LongAdder(); // DoubleAdder同理
线程中:adder.add(x);// 不论是add还是increment都不会直接添加到总量上,而是添加到当前分量上
分线程执行完毕后:adder.sum(); // 获取总量

LongAccumulator和DoubleAccumulator把分量思想应用到其他运算上:
LongAccumulator accumulator = new LongAccumulator(Long::sum,0); 
    // 第一参数为满足交换律的函数引用,第二参数为分量初始值
分量累计:accumulator.accumulate(x);
总量获取:accumulator.get();

延时回调

Callable, Future和FutureTask

Callable<T>:比之于Runnable它是一个有返回的异步接口,
    T call() throws Exception;

Future<T>:保存异步计算的结果,Future
    T get([long timeout, TimeUnit unit]): 
        调用此方法会被阻塞,直到计算完成;可能会抛出InterruptedException或TimeoutException
    boolean isDone():
        任务结束返回true(自然死亡或异常死亡或中止执行)
    boolean cancel(boolean mayInterrupt):
        如果未开始直接取消,如果已经开始运算且mayInterrupt参数为true则中断线程。取消成功返回true
    boolean isCancelled():
        是否在完成前就被取消了

FutureTask包装器:利用它把Callable对象转换成Runnable和Future;
    Callable<String> callable = new MyCallable();
    FutureTask<String> futureTask = new FutureTask<>(callable);
    Thread t = new Thread(futureTask); // Runnable
    t.start();
    ...
    String result = futureTask.get(); // Future

线程池

开辟线程消耗资源大,应该使用线程池限制并发线程数目(复用Thread对象);

【未完待续】

猜你喜欢

转载自blog.csdn.net/Fantastic_/article/details/79690585