• 【面试专栏】java线程第二篇:Java线程/并发编程带来的问题(含线程题解答)


    并发编程的问题

    1. 线程引入开销:上下文切换

    使用多线程编程时影响性能的首先是线程的上下文切换。每个线程占有一个CPU的时间片,然后会保存该线程的状态,切换到下一个线程执行。线程的状态的保存与加载就是上下文切换。
    减少上下文切换的方法有:无锁并发编程、CAS、使用最少线程、协程。

    • 无锁并发:通过某种策略(比如hash分隔任务)使得每个线程不共享资源,避免锁的使用。
    • CAS:是比锁更轻量级的线程同步方式
    • 线程池:避免创建不需要的线程,避免线程一直处于等待状态
    • 协程:单线程实现多任务调度,单线程维持多任务切换

    vmstat可以查看上下文切换次数
    jstack可以dump 线程信息,查看一个进程中各个线程的状态

    2. 内存同步

    内存不同步可能会导致线上问题,比如redis 连接池在 jedis issues 1920 中,因为内存不同步,在多线程环境下导致连接泄漏,直接现象就是获取连接的请求全部hold住卡死

    • 内存同步:在synchronized和volatile提供的可见性保证中可能会使用一些特殊指令,即内存栅栏。内存栅栏可以刷新缓存,使缓存失效,刷新硬件的写缓冲,以及停止执行管道。
    • 内存栅栏可能同样会对性能带来间接的影响,因为它们将抑制一些编译器优化操作。在内存栅栏中,大多数操作都是不能被重排序。
      不要担心非竞争同步带来的开销,这个基本的机制已经非常快了,并且JVM还能进行额外的优化以进一步降低或消除开销。因此,我们应该将优化重点放在那些发生锁竞争的地方。

    3. 死锁

    死锁后会陷入循环等待中,同样是导致程序bug

    如何避免死锁?

    • 避免一个线程同时获取多个锁
    • 避免一个线程在锁内占用多个资源,尽量保证每个锁只占用一个资源
    • 获取多个锁应该保证获取顺序一致,先拿先放、后拿后放
    • 尝试使用定时锁tryLock替代阻塞式的锁
    • 对于数据库锁,加锁和解锁必须在一个数据库连接中,否则会解锁失败,同时配置好失效机制(断开连接释放、超时释放)

    4. 线程安全性(原子性+可见性)

    • 1、对象的状态:对象的状态是指存储在状态变量中的数据,对象的状态可能包括其他依赖对象的域。在对象的状态中包含了任何可能影响其外部可见行为的数据。

    • 2、一个对象是否是线程安全的,取决于它是否被多个线程访问。这指的是在程序中访问对象的方式,而不是对象要实现的功能。当多个线程访问某个状态变量并且其中有一个线程执行写入操作时,必须采用同步机制来协同这些线程对变量的访问。同步机制包括synchronized、volatile变量、显式锁、原子变量。

    • 3、有三种方式可以修复线程安全问题:

      • 不在线程之间共享该状态变量
      • 将状态变量修改为不可变的变量
      • 在访问状态变量时使用同步
    • 4、线程安全性的定义:当多个线程访问某个类时,不管运行时环境采用何种调度方式或者这些线程将如何交替执行,并且在主调代码中不需要任何额外的同步,这个类都能表现出正确的行为,那么就称这个类是线程安全的。

    • 5、无状态变量一定是线程安全的,比如局部变量。

    • 6、读取-修改-写入操作序列,如果是后续操作是依赖于之前读取的值,那么这个序列必须是串行执行的。在并发编程中,由于不恰当的执行时序而出现不正确的结果是一种非常重要的情况,它称为竞态条件(Race Condition)。最常见的竞态条件类型就是先检查后执行的操作,通过一个可能失效的观测结果来决定下一步的操作,比如CAS、数据库中的update x1 = n where x1 = n2。

    • 7、复合操作:要避免竞态条件问题,就必须在某个线程修改该变量时,通过某种方式防止其他线程使用这个变量,从而确保其他线程只能在修改操作完成之前或之后读取和修改状态,而不是在修改状态的过程中。假定有两个操作A和B,如果从执行A的线程看,当另一个线程执行B时,要么将B全部执行完,要么完全不执行B,那么A和B对彼此来说就是原子的。原子操作是指,对于访问同一个状态的所有操作来说,这个操作是一个以原子方式执行的操作。
      为了确保线程安全性,读取-修改-写入序列必须是原子的,将其称为复合操作。复合操作包含了一组必须以原子方式执行的接口以确保线程安全性。

    • 8、在无状态的类中添加一个状态时,如果这个状态完全由线程安全的对象来管理,那么这个类仍然是线程安全的。(比如原子变量)

    • 9、如果多个状态是相关的,需要同时被修改,那么对多个状态的操作必须是串行的,需要进行同步。要保持状态的一致性,就需要在单个原子操作中更新所有相关的状态变量。

    • 10、内置锁:synchronized(object){同步块}
      Java的内置锁相当于一种互斥体,这意味着最多只有一个线程能持有这种锁,当线程A尝试获取一个由线程B持有的锁时,线程A必须等待或阻塞,直到线程B释放这个锁。如果B永远不释放锁,那么A也将永远地等待下去。

    • 11、可重入:当某个线程请求一个由其他线程持有的锁时,发出请求的线程就会阻塞。然而,由于内置锁是可重入的,因此如果某个线程试图获得一个已经由它自己持有的锁,那么这个请求就会成功。重入意味着获取锁的操作的粒度是线程,而不是调用。重入的一种实现方法是,为每个锁关联一个获取计数值和一个所有者线程。当计数值为0时,这个锁就被认为是没有被任何线程持有。当线程请求一个未被持有的锁时,JVM将记下锁的持有者,并且将获取计数值置1。如果一个线程再次获取这个锁,计数值将递增,而当线程退出同步代码块时,计数值会相应递减。当计数值为0时,这个锁将被释放。

    • 12、对于可能被多个线程同时访问的可变状态变量,在访问它时都需要持有同一个锁,在这种情况下,我们称状态变量是由这个锁保护的。
      每个共享的和可变的变量都应该只由一个锁来保护,从而使维护人员知道是哪一个锁。
      一种常见的加锁约定是,将所有的可变状态都封装在对象内部,并提供对象的内置锁(this)对所有访问可变状态的代码路径进行同步。在这种情况下,对象状态中的所有变量都由对象的内置锁保护起来。

    • 13、不良并发:要保证同步代码块不能过小,并且不要将本应是原子的操作拆分到多个同步代码块中。应该尽量将不影响共享状态且执行时间较长的操作从同步代码块中分离出去,从而在这些操作的执行过程中,其他线程可以访问共享状态。

    • 14、可见性:为了确保多个线程之间对内存写入操作的可见性,必须使用同步机制。

    • 15、加锁与可见性:当线程B执行由锁保护的同步代码块时,可以看到线程A之前在同一个同步代码块中的所有操作结果。如果没有同步,那么就无法实现上述保证。加锁的含义不仅仅局限于互斥行为,还包括内存可见性。为了确保所有线程都能看到共享变量的最新值,所有执行读操作或写操作的线程都必须在同一个锁上同步。

    • 16、volatile变量:当把变量声明为volatile类型后,编译器与运行时都会注意到这个变量是共享的,因此不会将该变量上的操作与其他内存操作一起重排序。volatile变量不会被缓存在寄存器或其他对处理器不可见的地方,因此在读取volatile类型的变量时总会返回最新写入的值。volatile的语义不足以确保递增操作的原子性,除非你能确保只有一个线程对变量执行写操作。原子变量提供了“读-改-写”的原子操作,并且常常用做一种更好的volatile变量。

    • 17、加锁机制既可以确保可见性,又可以确保原子性,而volatile变量只能确保可见性

    • 18、当且仅当满足以下的所有条件时,才应该使用volatile变量:

      • 1)你能确保只有单个线程更新变量的值。
      • 2)该变量不会与其他状态变量一起纳入不可变条件中
      • 3)在访问变量时不需要加锁
    • 19、栈封闭:在栈封闭中,只能通过局部变量才能访问对象。维护线程封闭性的一种更规范的方法是使用ThreadLocal,这个类能使线程的某个值与保存值的对象关联起来,ThreadLocal通过了get和set等访问接口或方法,这些方法为每个使用该变量的线程都存有一份独立的副本,因此get总是返回由当前执行线程在调用set时设置的最新值。

    • 20、在并发程序中使用和共享对象时,可以使用一些使用的策略,包括:

      • 线程封闭:线程封闭的对象只能由一个线程拥有,对象被封闭在该线程中,并且只能由这个线程修改。
      • 只读共享:在没有额外同步的情况下,共享的只读对象可以由多个线程并发访问,但任何线程都不能修改它。共享的只读对象包括不可变对象和事实不可变对象(从技术上来说是可变的,但其状态在发布之后不会再改变)。
      • 线程安全共享:线程安全的对象在其内部实现同步,因此多个线程可以通过对象的公有接口来进行访问而不需要进一步的同步。
      • 保护对象: 被保护的对象只能通过持有对象的锁来访问。保护对象包括封装在其他线程安全对象中的对象,以及已发布并且由某个特定锁保护的对象。
    • 21、饥饿:当线程由于无法访问它所需要的资源而不能继续执行时,就发生了饥饿(某线程永远等待)。引发饥饿的最常见资源就是CPU时钟周期。
      比如线程的优先级问题。在Thread API中定义的线程优先级只是作为线程调度的参考。在Thread API中定义了10个优先级,JVM根据需要将它们映射到操作系统的调度优先级。这种映射是与特定平台相关的,因此在某个操作系统中两个不同的Java优先级可能被映射到同一优先级,而在另一个操作系统中则可能被映射到另一个不同的优先级。
      当提高某个线程的优先级时,可能不会起到任何作用,或者也可能使得某个线程的调度优先级高于其他线程,从而导致饥饿。
      通常,我们尽量不要改变线程的优先级,只要改变了线程的优先级,程序的行为就将与平台相关,并且会导致发生饥饿问题的风险
      事务T1封锁了数据R,事务T2又请求封锁R,于是T2等待。T3也请求封锁R,当T1释放了R上的封锁后,系统首先批准了T3的请求,T2仍然等待。然后T4又请求封锁R,当T3释放了R上的封锁之后,系统又批准了T的请求…T2可能永远等待

    • 22、活锁
      活锁是另一种形式的活跃性问题,该问题尽管不会阻塞线程,但也不能继续执行,因为线程将不断重复执行相同的操作,而且总会失败。
      活锁通常发生在处理事务消息的应用程序中。如果不能成功处理某个消息,那么消息处理机制将回滚整个事务,并将它重新放到队列的开头。虽然处理消息的线程并没有阻塞,但也无法继续执行下去。
      这种形式的活锁通常是由过度的错误恢复代码造成的,因为它错误地将不可修复的错误作为可修复的错误
      当多个相互协作的线程都对彼此进行响从而修改各自的状态,并使得任何一个线程都无法继续执行时,就发生了活锁。
      要解决这种活锁问题,需要在重试机制中引入随机性,例如:MQ中的死信队列,经过多少次失败之后,认为这个消息无法处理,然后通过其他流程或者人工介入处理。
      在并发应用程序中,通过等待随机长度的时间和回退可以有效地避免活锁的发生。

    • 23、锁阻塞
      当在锁上发生竞争时,竞争失败的线程肯定会阻塞。JVM在实现阻塞行为时,可以采用自旋等待(Spin-Waiting,指通过循环不断地尝试获取锁,直到成功),或者通过操作系统挂起被阻塞的线程
      这两种方式的效率高低,取决于上下文切换的开销以及在成功获取锁之前需要等待的时间。如果等待时间较短,则适合采用自旋等待的方式,而如果等待时间较长,则适合采用线程挂起方式。

    • 24、如何减短锁的性能消耗
      有两个因素将影响在锁上发生竞争的可能性:锁的请求频率,以及每次持有该锁的时间。如果二者的乘积很小,那么大多数获取锁的操作都不会发生竞争,会因此在该锁上的竞争不会对可伸缩性造成严重影响。然而,如果在锁上的请求量很高,那么需要获取该锁的线程将被阻塞并等待。
      在极端情况下,即使仍有大量工作等待完成,处理器也会被闲置。
      有3种方式可以降低锁的竞争程度:

      • 减少锁的持有时间:缩小锁的范围,将与锁无关的代码移出同步代码块,尤其是开销较大的操作以及可能被阻塞的操作(IO操作)。当把一个同步代码块分解为多个同步代码块时,反而会对性能提升产生负面影响。在分解同步代码块时,理想的平衡点将与平台相关,但在实际情况中,仅可以将一些大量的计算或阻塞操作从同步代码块移出时,才应该考虑同步代码块的大小。
      • 减小锁的粒度:锁分解和锁分段
        锁分解是采用多个相互独立的锁来保护独立的状态变量,从而改变这些变量在之前由单个锁来保护的情况。这些技术能减小锁操作的粒度,并能实现更高的可伸缩性,然而,使用的锁越多,那么发生死锁的风险也就越高。
        锁分段:比如JDK1.7及之前的ConcurrentHashMap采用的方式就是分段锁的方式。
      • 降低锁的请求频率
        使用带有协调机制的独占锁,这些机制允许更高的并发性比如读写锁,并发容器等

    解答问题

    1. 线程的状态

    答:java线程中的状态在java.lang.Thread.State中可以看到,java线程状态是对操作系统层面的线程状态做了抽象对应的。java线程状态有:新建、可运行、阻塞、等待、超时等待、终止;操作系统层面的线程状态为3(就绪、运行、等待)+ 2(新建、终止)
    java 线程状态图

    操作系统状态图

    2. 线程的几种实现方式

    答:在线程第一篇中介绍了三种:1、继承线程;2、实现Runnable接口;3、实现Callbale接口

    3. 三个线程轮流打印ABC十次

    答:这个题主要考验线程时间片分配执行问题,使用 synchronized+wait/notify(还可以用join、Lock、Lock Condition 、Semaphore 等方式实现)

    class Wait_Notify_ACB {
    ​
        private int num;
        private static final Object LOCK = new Object();
    ​
        private void printABC(int targetNum) {
                synchronized (LOCK) {
                    while (num % 3 != targetNum) {   
                        try {
                            LOCK.wait();
                        } catch (InterruptedException e) {
                            e.printStackTrace();
                        }
                    }
                    num++;
                    System.out.print(Thread.currentThread().getName());
                    LOCK.notifyAll();
                }
        }
        
        public static void main(String[] args) {
            Wait_Notify_ACB  wait_notify_acb = new Wait_Notify_ACB ();
            new Thread(() -> {
                wait_notify_acb.printABC(0);
            }, "A").start();
            new Thread(() -> {
                wait_notify_acb.printABC(1);
            }, "B").start();
            new Thread(() -> {
                wait_notify_acb.printABC(2);
            }, "C").start();
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33

    4. 判断线程是否销毁

    答:可以根据线程的状态判断

    5. yield功能

    答:Thread.yield(),一定是当前线程调用此方法,当前线程放弃获取的cpu时间片,由运行状态变会可运行状态,让OS再次选择线程。作用:让相同优先级的线程轮流执行,但并不保证一定会轮流执行。实际中无法保证yield()达到让步目的,因为让步的线程还有可能被线程调度程序再次选中。Thread.yield()不会导致阻塞。

    6. 给定三个线程t1,t2,t3,如何保证他们依次执行

    答:可以使用join 方法

    
    
    public class JoinTest2 {
     
        // 1.现在有T1、T2、T3三个线程,你怎样保证T2在T1执行完后执行,T3在T2执行完后执行
     
        public static void main(String[] args) {
     
            final Thread t1 = new Thread(new Runnable() {
     
                @Override
                public void run() {
                    System.out.println("t1");
                }
            });
            final Thread t2 = new Thread(new Runnable() {
     
                @Override
                public void run() {
                    try {
                        // 引用t1线程,等待t1线程执行完
                        t1.join();
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                    System.out.println("t2");
                }
            });
            Thread t3 = new Thread(new Runnable() {
     
                @Override
                public void run() {
                    try {
                        // 引用t2线程,等待t2线程执行完
                        t2.join();
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                    System.out.println("t3");
                }
            });
            t3.start();//这里三个线程的启动顺序可以任意,大家可以试下!
            t2.start();
            t1.start();
        }
    }
    
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46
    • 47
  • 相关阅读:
    Elasticsearch:通过 JDBC 使用 SQL 来查询索引 - DBeaver
    【字符编码系列一】ASCII编码是什么?
    多线程的创建、线程的状态和调度and同步、join和yield以及单例设计模式的种类
    Java文件读取
    Flask框架配置Celery-[2]:将前端上传的文件,通过异步任务保存,异步处理上传的文件
    数据库——知识1
    h5禁止滚动穿透(页面)
    wireshark 使用实践
    Java自学注意细节快速成长
    昭通市鲁甸县卯家湾安置区:凝心聚力 共谱民族团结进步新篇章
  • 原文地址:https://blog.csdn.net/qq_35530042/article/details/126245357