并发编程(9)-并发安全

线程安全

    当多个线程访问某个类时,不管运行时环境采用何种调度方式或者这些线程将如何交替执行,并且在调用代码中不需要任何额外的同步或者协同,这个类都能表现出正确的行为,那么就称这个类是线程安全的。

线程封闭

    实现好的并发是一件困难的事情,所以很多时候我们都想躲避并发。避免并发最简单的方法就是线程封闭。
    线程封闭就是把对象封装到一个线程里,只有这一个线程能看到此对象。那么这个对象就算不是线程安全的也不会出现任何安全问题。

ad-hoc 线程封闭

    这是完全靠实现者控制的线程封闭,他的线程封闭完全靠实现者实现。
    Ad-hoc 线程封闭非常脆弱,应该尽量避免使用。

栈封闭

    栈封闭是我们编程当中遇到的最多的线程封闭。什么是栈封闭呢?简单的说就是局部变量。多个线程访问一个方法,此方法中的局部变量都会被拷贝一份到线程栈中。所以局部变量是不被多个线程所共享的,也就不会出现并发问题。所以能用局部变量就别用全局的变量,全局变量容易引起并发问题。

无状态的类

    没有任何成员变量的类,就叫无状态的类,这种类一定是线程安全的。即使这个类的方法参数中使用了对象,也是线程安全的。因为多线程下的使用,方法参数这个对象的实例会可能不正常,但是对于这个类的对象实例来说,它并不持有方法参数的对象实例,它自己并不会有问题。

让类不可变

1,加final 关键字:对于一个类,所有的成员变量应该是私有的,并且所有的成员变量应该加上final 关键字;如果成员变量又是一个对象时,这个对象所对应的类也要是不可变,才能保证整个类是不可变的。
2、不提供任何可供修改成员变量的地方,同时成员变量也不作为方法的返回值:如果类的成员变量中有对象,上述的final 关键字保证不可变并不能保证类的安全性,因为在多线程下,虽然对象的引用不可变,但是对象在堆上的实例是有可能被多个线程同时修改的,没有正确处理的情况下,对象实例在堆中的数据是不可预知的。这就需要安全的发布这个对象。

volatile

并不能保证类的线程安全性,只能保证类的可见性,最适合一个线程写,多个线程读的情景。

加锁和CAS

常用的保证线程安全的手段,使用 synchronized 关键字,使用显式锁,使用各种原子变量,修改数据时使用CAS 机制等等。

安全的发布

    类中持有的成员变量,如果是基本类型,发布出去,并没有关系,因为发布出去的其实是这个变量的一个副本。
    但是如果类中持有的成员变量是对象的引用,如果这个成员对象不是线程安全的,通过get 等方法发布出去,会造成这个成员对象本身持有的数据在多线程下不正确的修改,从而造成整个类线程不安全的问题。

    这个 list 发布出去后,是可以被外部线程之间修改,那么在多个线程同时修改的情况下不安全问题是肯定存在的,怎么修正这个问题呢?我们在发布这对出去的时候,就应该用线程安全的方式包装这个对象。将 list 用 Collections.synchronizedList 进行包装以后,无论多少线程使用这个 list,就都是线程安全的了。

    对于我们自己使用或者声明的类,JDK 自然没有提供这种包装类的办法,但是我们可以仿造这种模式或者委托给线程安全的类,当然,对这种通过get 等方法发布出去的对象,最根本的解决办法还是应该在实现上就考虑到线程安全问题。

TheadLocal

    ThreadLocal 是实现线程封闭的最好方法。ThreadLocal 内部维护了一个Map,Map 的key 是每个线程的名称,而Map 的值就是我们要封闭的对象。每个线程中的对象都对应着Map 中一个值,也就是ThreadLocal 利用Map 实现了对象的线程封闭。

Servlet 辨析

不是线程安全的类,为什么我们平时没感觉到:
1、在需求上,很少有共享的需求,
2、接收到了请求,返回应答的时候,一般都是由一个线程来负责的。
但是只要 Servlet 中有成员变量,一旦有多线程下的写,就很容易产生线程安全问题。

死锁

    指两个或两个以上的进程在执行过程中,由于竞争资源或者由于彼此通信而造成的一种阻塞的现象,若无外力作用,它们都将无法推进下去。此时称系统处于死锁状态或系统产生了死锁。

1、多个操作者 M >= 2
2、竞争多个资源 N >= 2 && N <= M
3、竞争资源的顺序不对

单线程不会有死锁。
单资源只会产生激烈的竞争,也不会产生死锁。

产生死锁的四个必要条件

1)互斥条件:

    指进程对所分配到的资源进行排它性使用,即在一段时间内某资源只由一个进程占用。如果此时还有其它进程请求资源,则请求者只能等待,直至占有资源的进程用毕释放。

2)请求和保持条件:

    指进程已经保持至少一个资源,但又提出了新的资源请求,而该资源已被其它进程占有,此时请求进程阻塞,但又对自己已获得的其它资源保持不放。

3)不剥夺条件:

    指进程已获得的资源,在未使用完之前,不能被剥夺,只能在使用完时由自己释放。

4)环路等待条件:

    指在发生死锁时,必然存在一个进程——资源的环形链,即进程集合 {P0,P1,P2,···,Pn} 中的 P0 正在等待一个 P1 占用的资源;P1 正在等待 P2 占用的资源,... ..., Pn 正在等待已被 P0 占用的资源。

只要打破四个必要条件之一就能有效预防死锁的发生:

打破互斥条件:

改造独占性资源为虚拟资源,大部分资源已无法改造。

打破不可抢占条件:

当一进程占有一独占性资源后又申请一独占性资源而无法满足,则退出原占有的资源。

打破占有且申请条件:

采用资源预先分配策略,即进程运行前申请全部资源,满足则运行,不然就等待,这样就不会占有且申请。

打破循环等待条件:

实现资源有序分配策略,对所有设备实现分类编号,所有进程只能采用按序号递增的形式申请资源。

数据库的死锁

MySQL
-- 查看当前的事务
SELECT * FROM information_schema.INNODB_TRX;

-- 查看当前锁定的事务
SELECT * FROM information_schema.INNODB_LOCKS;

-- 查看当前等待锁的事务
SELECT * FROM information_schema.INNODB_LOCK_WAITS;

-- 杀死进程id(trx_mysql_thread_id列)
kill 线程ID
Oracle
-- dba用户 查看是否有死锁
select username,lockwait,status,machine,program from v$session
where sid in(select session_id from v$locked object);
-- username 死锁语句所用的数据库用户
-- lockwait 死锁的状态,如果有内容表示被死锁
-- status 状态,active表示被死锁
-- machine 死锁语句所在的机器
-- program 产生死锁的语句主要来自哪个应用程序

-- dba用户 查看被死锁的语句
select sql_text from v$sql
where hash_value in (
	select sql_hash_value from v$session where sid in(
		select session_id from v$locked_object
	)
)

-- dba用户 查找死锁的进程
select s.username, l.object_id,l.session_id,s.serial#,l.oracle_username,l.os_user_name,l.process
from v$locked_object l, v$session s
where l.session_id = s.sid

-- kill掉死锁进程 sid = l.session_id
alter system kill session 'sid,serial#';

Java中的死锁

public class TestDeadLock {
    private static Object lock1 = new Object();
    private static Object lock2 = new Object();

    public static void method1() {
        synchronized (lock1) {
            SleepTools.ms(300);
            synchronized (lock2) {
                System.out.println("method1");
            }
        }
    }

    public static void method2() {
        synchronized(lock2) {
            SleepTools.ms(300);
            synchronized (lock1) {
                System.out.println("method1");
            }
        }
    }

    public static void main(String[] args) {
        new Thread(() -> {
            method1();
        }).start();
        method2();
    }
}

危害

应用程序是活着的,但不工作了

没有异常信息供检查

无法恢复,只能重启

特点

时间不确定,不是每次必现

无异常信息,只发现应用的业务越来越慢,最后停止服务,无法定位具体哪个业务导致的问题

测试无法重现,并发量不够

解决

定位

通过 jps 查询应用的 id,再通过 jstack id 查看应用的锁持有情况

解决

1、内部通过顺序比较,确定拿锁的顺序

2、采用尝试拿锁的机制

其他线程安全问题

活锁

    两个线程在尝试拿锁的机制中,发生线程之间的谦让;不断发生同一个线程总是拿到同一把锁,在尝试拿另一把锁时因为拿不到而将本来已经持有的锁释放的过程。

    解决办法:每个线程休眠随机数,错开拿锁的时间。

线程饥饿

低优先级的线程,总是得不到执行时间。

并发下的性能

    引入多线程后,会导致额外的开销:如线程之间的协调、线程上下文的切换、线程的创建和销毁、线程调度等。过度或不恰当的使用会导致多线程程序甚至比单线程程序效率还低。

    衡量应用程序的性能:服务时间、延迟时间、吞吐量、可伸缩性等;

    延迟时间——多快、吞吐量——多少是完全独立甚至是相互矛盾的;

    对服务器应用来说:多少比多快更重重视;

线程引入的开销

上下文切换

    如果主线程是唯一的线程,那么它基本上不会被调度出去。另一方面,如果可运行的线程数大于 CPU 的数量,那么操作系统最终会将某个正在运行的线程调度出来,从而使其他线程能够使用 CPU。这将导致一次上下文切换,在这个过程中将保存当前运行线程的执行上下文,并将新调度进来的线程的执行上下文设置为当前上下文。上下文切换有点像我们同时阅读几本书,在来回切换书本的同时我们需要记住每本书当前读到的页码。
    切换上下文需要一定的开销,而在线程调度过程中需要访问由操作系统和 JVM 共享的数据结构。应用程序、操作系统以及 JVM 都使用一组相同的 CPU。
    在 JVM 和操作系统的代码中消耗越多的 CPU 时钟周期,应用程序的可用 CPU 时钟周期就越少。但上下文切换的开销并不只是包含 JVM 和操作系统的开销。当一个新的线程被切换进来时,它所需要的数据可能不在当前处理器的本地缓存中,因此上下文切换将导致一些缓存缺失,因而线程在首次调度运行时会更加缓慢。
    当线程由于等待某个发生竞争的锁而被阻塞时, JVM 通常会将这个线程挂起,并允许它被交换出去。如果线程频繁地发生阻塞,那么它们将无法使用完整的调度时间片。在程序中发生越多的阻塞(包括阻塞IO,等待获取发生竞争的锁,或者在条件变量上等待),与 CPU 密集型的程序就会发生越多的上下文切换,从而增加调度开销,并因此而降低吞吐量。
    上下文切换是计算密集型操作。也就是说,它需要相当可观的处理器时间。
    所以,上下文切换对系统来说意味着消耗大量的 CPU 时间,事实上,可能是操作系统中时间消耗最大的操作。上下文切换的实际开销会随着平台的不同而变化,然而按照经验来看:在大多数通用的处理器中,上下文切换的开销相当于 50~10000 个时钟周期,也就是几微秒。
    UNIX 系统的 vmstat 命令能报告上下文切换次数以及在内核中执行时间所占比例等信息。如果内核占用率较高(超过10%),那么通常表示调度活动发生得很频繁,这很可能是由 IO 或竞争锁导致的阻塞引起的。

内存同步

    同步操作的性能开销包括多个方面。在 synchronized 和 volatile 提供的可见性保证中可能会使用一些特殊指令,即内存栅栏( Memory Barrier)。
    内存栅栏可以刷新缓存,使缓存无效刷新硬件的写缓冲,以及停止执行管道。
    内存栅栏可能同样会对性能带来间接的影响,因为它们将抑制一些编译器优化操作。在内存栅栏中,大多数操作都是不能被重排序的。

阻塞

    引起阻塞的原因:包括阻塞IO,等待获取发生竞争的锁,或者在条件变量上等待等等。
    阻塞会导致线程挂起【挂起:挂起进程在操作系统中可以定义为暂时被淘汰出内存的进程,机器的资源是有限的,在资源不足的情况下,操作系统对在内存中的程序进行合理的安排,其中有的进程被暂时调离出内存,当条件允许的时候,会被操作系统再次调回内存,重新进入等待被执行的状态即就绪态,系统在超过一定的时间没有任何动作】。
    很明显这个操作至少包括两次额外的上下文切换,还有相关的操作系统级的操作等等。

如何减少锁的竞争

减小锁的粒度

    使用锁的时候,锁所保护的对象是多个,当这些多个对象其实是独立变化的时候,不如用多个锁来分别保护这些对象。但是如果有同时要持有多个锁的业务方法,要注意避免发生死锁。

缩小锁的范围

    对锁的持有实现快进快出,尽量缩短持由锁的的时间。将一些与锁无关的代码移出锁的范围,特别是一些耗时,可能阻塞的操作。

避免多余的锁

    两次加锁之间的语句非常简单,导致加锁的时间比执行这些语句还长,这个时候应该进行锁粗化—扩大锁的范围。

锁分段

    ConcurrrentHashMap 就是典型的锁分段。

替换独占锁

在业务允许的情况下:
1、使用读写锁,
2、用自旋CAS
3、使用系统的并发容器

线程安全的单例模式

_____个人笔记_____((≡^⚲͜^≡))_____欢迎指正_____

  • 45
    点赞
  • 42
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值