【多线程基础专题】19、关于线程安全 + 锁的一些概念性问题(重要)
本问题对应的项目链接:高频题回答模版训练
说明:这里主要问一些概念问题,侧重「理解」,理解一个东西,举例子最为重要哦,然后面试一般就这些问题的比较多。
1、你是怎么理解线程不安全的?线程不安全会带来哪些问题?(回答指导:采用经典的 i++ 来回答不安全,至于带来什么问题,如果可以距离实际项目那最好了)
2、多线程环境下,我们可以怎么解决线程不安全的问题?
3、刚才说到了锁,那你觉得锁怎是么实现线程安全?(回答指导:这个得从操作系统角度回答了)
4、刚才我们说到了锁,乐观锁和悲观锁了解吗?可以说一说他们的区别吗?
5、Java 中有些是基于乐观锁实现的,有些是基于悲观锁实现的,你了解到的有哪些呢?
6、死锁了解吗?可以简单说下死锁以及怎么解决死锁问题的不?
7、java里面的线程和操作系统的线程一样吗?比如我在Java中创建了一个线程,那么操作系统也会创建一个线程吗?
共以下 9 个回答
1、你是怎么理解线程不安全的?线程不安全会带来哪些问题?
线程不安全是指在多个线程对同一个数据进行增删改查的时候出现结果与预期数据不匹配的情况。比如说有段代码是i = 0的情况下执行i++,有两个线程来执行代码,预期结果应该是i=2,但是线程A和线程B同时进入代码,他们读取到的i都是0,则他们在各自的工作空间内将i+1变为1并写入内存,则最终的结果i会等于1。线程不安全的话,例如在我的电商项目中,同时很多用户购买同一个商品并下单,因为商品下单后需要扣库存,在大量线程涌入的情况下可能会导致超卖的情况。
2、多线程环境下,我们可以怎么解决线程不安全的问题?
我们可以加锁来解决线程不安全问题,给需要上锁的对象设置Synchronized等代码块,当一个线程访问该对象的时候获取锁,在锁被释放以前其他线程将无法访问该对象。
3、刚才说到了锁,那你觉得锁怎是么实现线程安全?
在操作共享资源的时候,对该共享资源加锁进入临界区,使该线程能独占该资源,操作完成后释放锁。如果获取锁失败就进入阻塞状态,被唤醒后再次尝试获取锁。在操作系统中比较简单的方式就是使用信号量,信号量就是一个整数初始化为1,获取锁就对该信号量-1,释放锁就+1,如果信号量 <=0就说明有其他线程持有该锁,进入阻塞状态。
4、刚才我们说到了锁,乐观锁和悲观锁了解吗?可以说一说他们的区别吗?
悲观锁的意思是线程在访问加锁对象的时候一定会上锁,防止其他线程进行访问,而乐观锁是访问对象的时候不会上锁,当出现资源竞争的时候在做处理。
5、Java 中有些是基于乐观锁实现的,有些是基于悲观锁实现的,你了解到的有哪些呢?
synchronized关键字,reentrantlock类是基于悲观锁实现的。Atomic包中的一些类,比如AtomicInteger就是通过CAS的方式实现乐观锁。
6、死锁了解吗?可以简单说下死锁以及怎么解决死锁问题的不?
死锁是因为若干线程在占有资源的同时,又在等待其他线程所占有的资源的释放,最终导致程序一直被阻塞。发生死锁的条件有四点,打破一点就可以破坏死锁
1.互斥。同一时间只能有同一线程获取到同一个锁
2.占有且等待。一个进程在等待其他进程释放资源的同时,继续占有已经拥有的资源。
3.不可抢占。其他线程不能强行抢占已获取到的锁
4.循环且等待。线程1等待线程2的锁,线程2等待线程1持有的锁,形成循环等待。
正常情况下我们都使用互斥锁来保证写操作的原子性,所以互斥条件一般不会打破。对于占有且等待,可以要求进程在运行之前一次性获取所有需要的资源,并且阻塞进程直到可以一次性获取所有需要的资源。对于不可抢占,我们可以设置超时时间,如果无法获取到第二把锁就释放已占有的锁。对于循环且等待,我们可以为锁进行排序,先获取序号小的锁,再获取序号大的锁,这样各个线程获取锁的顺序都是一致的,就不会产生循环等待的现象。
7、java里面的线程和操作系统的线程一样吗?比如我在Java中创建了一个线程,那么操作系统也会创建一个线程吗?
java里面的线程是用户线程,是由JVM管理的。操作系统创建的是内核线程,是用来分配CPU资源的。而每一条用户线程的执行需要映射到一个操作系统的内核线程来完成任务,JAVA中创建了一个线程,操作系统中并不一定会创建一个线程,但是操作系统中没有线程的话,那么JAVA中的线程一定不会被执行。
1、你是怎么理解线程不安全的?线程不安全会带来哪些问题?(回答指导:采用经典的 i++ 来回答不安全,至于带来什么问题,如果可以距离实际项目那最好了)
我的理解是线程不安全指的是在多线程并发/并行操作共享资源时代码执行的结果与预期结果不一致。
以两个线程同时执行10000次i++为例,执行后的结果并不等于20000,一般都会小于20000。产生这个结果主要有两个原因一个是因为线程切换带来的原子性问题,另一个原因就是缓存带来的可见性问题。i++在代码编写的时候只有一行,但是在编译成字节码的时候会产生三个步骤,读取i的当前值,将i的当前值+1,将更新后的i写回内存。首先来说原子性问题,假如线程1正读取i的当前值为10,此时该线程的时间片用完切换到了线程2,线程2也读取当前i的值为10,并+1写回到内存此时内存中i的值为11,此时再切换到线程1,在当前值10的基础上+1写回内存,内存中的值仍为11,可见虽然两个线程执行了两次i++但最后结果指加了一次。
还有就是缓存带来的可见性问题,以java虚拟机的内存模型为例,每个线程都有线程私有的工作内存,还有线程共有的主内存,如果要对一个数据进行访问或修改首先要先从主内存中加载到工作内存中,再对数据进行操作,这就导致了不同工作内存数据不一致的问题,再说回刚才讲的例子假设有两个线程并行执行,没个线程都占有自己的cpu,线程1将i的当前值10加载到工作内存后+1,但是没有写回到主内存,线程2读取i的值到工作内存仍为10+1后写回主内存11,线程1也将11写回到主内存导致与预期结果不一致
还有一点就是指令重排序带来的有序性问题,jvm为了提高cpu的运算效率,会进行优化,也就是指令重排序,jvm会保证对于有数据依赖性的操作不进行重排序,来保证单线程环境下代码的正确性。但是多线程环境下,其他线程的运行顺序对于当前线程来讲是无序的,就会产生有序性的问题。以单例对象的双重检验锁为例
2、多线程环境下,我们可以怎么解决线程不安全的问题?
多线程环境下出现的问题可以总结为原子性,可见性,和有序性。
java提供了synchronized关键字以及ReentrantLock等工具类保证了原子性
volatile关键字以及happens-before原则保证了可见性
并且volatile和final关键字也解决了有序性的问题
3、刚才说到了锁,那你觉得锁怎是么实现线程安全?(回答指导:这个得从操作系统角度回答了)
就是在操作共享资源的时候,对该共享资源加锁进入临界区,使该线程能独占该资源,操作完成后释放锁。
如果获取锁失败就进入阻塞状态,被唤醒后再次尝试获取锁。
在操作系统中比较简单的方式就是使用信号量,信号量就是一个整数初始化为1,获取锁就对该信号量-1,释放锁就+1,如果信号量 <=0就说明有其他线程持有该锁,进入阻塞状态
4、刚才我们说到了锁,乐观锁和悲观锁了解吗?可以说一说他们的区别吗?
乐观锁指的是,在对共享资源操作时不会加锁,会先尝试修改该资源,如果在提交更新时发现有其他线程已经修改了该资源就对数据进行回滚,也就是取消操作。如果没有其他线程更新该数据就会进行更新。
悲观锁只要在进入临界区前都必须要获取该资源对应的锁,获取锁成功才会进入临界区,操作共享资源,操作结束后才会释放锁。
5、Java 中有些是基于乐观锁实现的,有些是基于悲观锁实现的,你了解到的有哪些呢?
synchronized关键字,reentrantlock类是基于悲观锁实现的。
Atomic包中的一些类,比如AtomicInteger就是通过CAS的方式实现乐观锁
6、死锁了解吗?可以简单说下死锁以及怎么解决死锁问题的不?
死锁的定义就是一组相互竞争资源的线程因为相互等待而导致永久阻塞。
就比如线程1完成一个操作首先要获取锁a,再获取锁b,而线程2需要先获取锁b再获取锁a,这样就会发生一种情况,线程1获取到了锁a,线程2获取到了锁b,线程1因为无法获取到锁b而阻塞,线程2因为无法获取到锁a而阻塞。
发生死锁的条件有四点,打破一点就可以破坏死锁
1.互斥。锁是互斥的,同一时间只能有同一线程获取到同一个锁
2.占有且等待。线程在获取到锁a后,必须等待获取到锁b,否则不会释放。
3.不可抢占。其他线程不能强行抢占已获取到的锁
4.循环且等待。线程1等待线程2的锁,线程2等待线程1持有的锁。
正常情况下我们都使用互斥锁来保证写操作的原子性,所以互斥条件一般不会打破。
对于占有且等待,我们可以一次性的获取两把锁,这样就不会有等待的过程。
对于不可抢占,我们可以设置超时时间,如果无法获取到第二把锁就释放已占有的锁。
对于循环且等待,我们可以为锁进行排序,先获取序号小的锁,再获取序号大的锁,这样各个线程获取锁的顺序都是一致的,就不会产生循环等待的现象。
7、java里面的线程和操作系统的线程一样吗?比如我在Java中创建了一个线程,那么操作系统也会创建一个线程吗?
在jdk1.2前 java开发者通过实现自己的线程库来实现线程,也就是用户级线程,通过用户空间的线程库来实现线程的调度,以及对线程生命周期的管理。优点就是不涉及用户态到内核态转换的开销,缺点就是对于操作系统而言只能看到进程,如果一个进程中的线程因为io或其他操作阻塞,该进程的其他线程也会被阻塞。
jdk1.2以后是基于操作系统的内核线程实现的,也就是说每创建一个java线程都会对应操作系统中的一个线程,由操作系统对线程进行调度和管理。
1 嗯,通俗的来说加锁的就是线程安全的,不加锁的就是现场不安全的。就比如说我们一个方法,在对一个共享变量i进行加操作的时候,多线程下就有可能会导致它会少加,因为读的时候两个线程读到相同的值,但是写的时候却把加一之后的值写入两次,其实本质上加的还是一次,这就会导致我这并不是我们想要的结果,所以说他不是县城安全的。
2 解决线程不安全的问题,通常来说就是通过加速实现,在JAVA中可以用synchronized或者说是用lock这个接口来实现加锁操作。加锁了之后,在同一时间就只能有一个线程来执行这个方法。核心思想还是实现对共享资源的互斥来实现加速。
3 操作系统实现锁的话主要是实现的线程对于共享资源的互斥访问。他把需要访问共享资源的那段代码称作临界区,然后在执行临界区的代码之前就需要先上锁在退出了临界区的代码之后就需要解锁。当然访问临界区需要遵循四个原则,就是说空闲让记,忙着等待,有序等待和让权等待这四个原则。基于这四个原则,具体实现互斥可以分为硬件实现和软件实现。软件实现的话,可以有单标志法,双标志先检查,双标之后检查或者皮特森算法,但是这四种方法都有他们各自的局限性。如果要用硬件来实现的话就只有通过中断屏蔽法来实现。其具体有两种实现方式,一种名为tsl,全称test and setLock,Tls是硬件来实现的,执行的过程中它不允许被中断,只能一气呵成。另一种叫做swap,可以称为exchange,对应的汇编指令就是xchg指令,本质上来说跟tsl是一样的逻辑。
4 乐观锁和悲观锁主要是概念上的不同。乐观所他总是保持乐观,他在获取所之前默认是没有其他的线程来跟他竞争锁,他在获取所失败之后不会阻塞,会循环尝试。而悲观所则不同,他总是会认为目前的竞争很大,如果说获取不到所的话,那么他就自己阻塞,等待前面的线程执行完他的事情之后才会被唤醒。乐观锁的实现方式通常就是使用cas加上自旋的这样操作来实现的。在所的竞争不是很激烈的情况下,他能够很快的获取到所并且不会产生阻塞,就不会有CPU的上下文切换,所以说一般来说效率比较高。但是他也有他的缺点,就是在竞争比较激烈的时候,CPU很有可能被自选操作一直占用,进而消耗性能。
5 首先来说JAVA中的乐观所实现,UC下的atomic integer这些原子类的话,他的increment操作就是使用的cas这种乐观所来进行实现的,它里面的value是一个volatile类型的变量,如果说cas失败的话,那么他会重新自旋进行。北关所的synchronized或者说是一些lock的实现。使用他们的时候,如果说加锁失败,那么线程就会阻塞,直到前面抢到锁的线程释放之后才会将他们唤醒。
6 死锁就是我们在进行并发控制的时候,可能会出现一些特殊的情况,导致互相占用资源进行的循环等待又不释放资源,一些县城就这么相互僵持,而无法使程序继续执行下去的情况。是所产生主要有四个条件,第一就是资源互斥,第二就是不可剥夺,第三就是占有且等待,第四个就是循环等待。要解决死锁问题的话,主要有两个大的方面,第一个就是思索预防,第二个就是如果真的发生了事,所以后如何解决。思索预防的话可以使用银行加算法来决定这个资源要不要分配给这个县城或者说是提前对于这些资源进行编号,只能让他们依次按照资源的序号进行获取,这样的话就不可能会导致相互持有资源不释放。而如果真正发生了死锁之后呢,我们可以破坏这四个必要条件中的其中一个就可以打破死锁的局面,比如说我们持有且等待的时候,等待了太长时间都没有等到的话,就可以将自己的资源释放掉了,但是需要注意的是资源互斥这个条件是不可以打破的,因为资源他一定是互斥的,否则就压根儿不可能出现死锁。
7 嗯,并不是一样的,JAVA里面的线程是用户级的用户线程,他是有Jvm来帮我们创建和管理的,操作系统的线程它通常指的是内核线程。我们创建一个用户线程,对于操作系统来说它是感知不到的,但是内核线程的话,它是操作系统真正分配CPU资源的单位。但是我们的用户线程在执行的时候,他确实是需要映射到一个操作系统的内核线程来完成它的任务的。所以说JAVA中创建了一个线程,操作系统中并不一定会创建一个线程,但是如果说操作系统中没有线程的话,那么JAVA中的线程一定不会被执行。
1、你是怎么理解线程不安全的?线程不安全会带来哪些问题?(回答指导:采用经典的 i++ 来回答不安全,至于带来什么问题,如果可以距离实际项目那最好了)
结合volitale关键字保证原子性,以for循环i++为例;
2、多线程环境下,我们可以怎么解决线程不安全的问题?
加锁Synchronized
volitale关键字
Lock
CAS
3、刚才说到了锁,那你觉得锁怎是么实现线程安全?(回答指导:这个得从操作系统角度回答了)
操作系统中的简单锁、缓存区
synchronized关键字的锁:依赖指令
4、刚才我们说到了锁,乐观锁和悲观锁了解吗?可以说一说他们的区别吗?
乐观锁、悲观锁、是什么?
乐观锁导致的问题,ABA、循环等待时间长、保证一个共享变量;
5、Java 中有些是基于乐观锁实现的,有些是基于悲观锁实现的,你了解到的有哪些呢?
原子类的CAS
synchronized
6、死锁了解吗?可以简单说下死锁以及怎么解决死锁问题的不?
死锁、必要条件、
预防死锁:破坏条件
避免死锁:安全状态、银行家算法
解决死锁:资源剥夺、撤销进程、进程回退
7、java里面的线程和操作系统的线程一样吗?比如我在Java中创建了一个线程,那么操作系统也会创建一个线程吗?
早期:绿色线程、JVM管理和调度,看不见,无法进行多线程操作
后期:1:1操作
1.当一个资源在同一个时刻被多个线程操作时,可以得到正确的结果就是这个资源就是线程安全的,如果得到错误的结果就是线程不安全的。线程不安全主要是由三个原因造成的,原子性、可见性、有序性,比如创建单例对象时,如果不使用volatile关键字修饰实例对象的指针,就可能因为JVM创建对象的步骤不是有序的而导致返回一个空对象的指针。多线程指向i++操作时由于可见性和原子性问题导致某一次更新操作丢失,导致最终结果偏小。还有多线程操作HashMap时,会出现put操作的覆盖问题和get操作的空值问题。
2.先分析引起线程不安全的原因是什么,对症下药,如果是由于指令重排序引起的有序性问题或者JVM内存模型导致的可见性问题,可以使用volatile关键字修饰,如果需要确保方法的原子性,可以使用syncronized关键字修饰方法,或者在执行代码块前先加锁,执行代码块后释放锁,确保代码块的原子性。
3.通过一个变量来判断当前资源的状态,是占用状态还是空闲状态,这一个变量在操作系统中被称为信号量。信号量被用来解决互斥和同步问题。信号量被初始化为1,线程A执行P操作后信号量值变为0,然后执行代码,线程B再执行P操作时信号量值变为-1,然后阻塞,等到线程A执行完临界区的代码后执行V操作,信号量值变为0,知道有一个线程被阻塞了,于是去唤醒线程B,线程B执行代码后,再执行V操作,信号量最后变成1。这样一来同一个时刻只能有一个线程进入临界区,实现了互斥。
4.线程认为自己可以在其它线程进入临界区之前完成对临界区的操作,所以不需要限制其它线程进入临界区,只需要根据资源是否发生变化来决定操作是成功还是失败,失败则重试。所以乐观锁适合临界区执行速度很快、锁冲突比较小的场景,如果临近区执行速度很慢或者锁冲突比较大,会大大增加执行过程中其它线程进入的概率,导致增加重试次数,并且重试的成功概率也会降低。而悲观锁是线程认为自己执行临界区的代码时,一定会有另外的线程进入临界区,所以它进入临界区之前会给加一把锁阻塞其它想进入临界区的线程,执行完毕后释放锁并唤醒其它阻塞的线程去竞争锁。不过因为因为阻塞和唤醒是一个重量级的操作,涉及到线程的状态转换,需要通过上下文切换完成,所以悲观锁一般适合临界区执行速度很长、锁冲突比较激烈的场景。
5.Java中一些原子类AtomicInteger、AtomicLong这些类只提供get和set方法,执行速度快所以底层使用乐观锁确保线程安全,而synchronized关键字底层也是采用悲观锁,不过有一个锁升级的过程,随着锁的竞争程度增加而升级,先由偏向锁升级为自旋锁,最后由自旋锁升级为悲观锁。另外synchronized关键字不支持中断、在指定时间范围内尝试获取锁、多个条件队列等特性,所以Java还提供了一些基于AQS同步框架实现的锁组件:ReetrantLock、ReetrantWriteReadLock来限制同一时刻进入临界区的线程,确保线程安全。
6.死锁就是一组线程去竞争一组相同资源的锁而导致一直阻塞。产生死锁有四个必要条件,如果想要避免死锁问题,只需要破坏一个必要条件。第一个条件是互斥,一个锁只能被一个线程占用,这个不能破坏,这个被破坏
1.当一个资源在同一个时刻被多个线程操作时,可以得到正确的结果就是这个资源就是线程安全的,如果得到错误的结果就是线程不安全的。线程不安全主要是由三个原因造成的,原子性、可见性、有序性,比如创建单例对象时,如果不使用volatile关键字修饰实例对象的指针,就可能因为JVM创建对象的步骤不是有序的而导致返回一个空对象的指针。多线程指向i++操作时由于可见性和原子性问题导致某一次更新操作丢失,导致最终结果偏小。还有多线程操作HashMap时,会出现put操作的覆盖问题和get操作的空值问题。
1.线程不安全就是在多线程环境下程序未能按预期的设计运行未能产生预期结果,比如多个线程同时对 i 进行 i++ 操作,因为 i++ 不是原子性的,大概率会出现相互覆盖的情况,导致 i 没有达到预期大小。在购物系统中,用户下单库存即减一,如果线程不安全,可能会出现超卖;
2.线程不安全可能有以下原因:(1)资源共享不合理;(2)线程只操作了自己的工作内存;(3)操作不是原子性的;(4)数据竞争;
对于这些问题,可以通过加锁来保护资源,通过信号量来同步线程,避免数据共享的写操作;
3.在操作系统中可能出现线程不安全的代码叫临界区,在进入临界区前会尝试加锁,如果加锁成功就能继续运行,否则只能阻塞,从临界区离开要解锁。Java实现与操作系统类似,使用对象作为锁,在临界区有monitorenter和monitorexit。用于获取和释放对象的monitor。Java的重量级锁底层就使用了操作系统提供的mutex lock来实现;
4.乐观锁就是在操作资源前假设没有其他线程竞争,进行操作后再确认,如果有竞争则采取措施,经典的实现有cas,乐观锁一般用在读多写少的情况。悲观锁假设竞争激烈,一定先加锁在操作;
5.Java中的原子类,比如AtomicInteger,, AtomicBoolean就是采取乐观锁cas实现;悲观锁有synchronized,reentrantLock;
6.死锁指多个线程占据资源和相互等待释放资源,死锁产生需要:(1)互斥锁;(2)锁不可剥夺;(3)保持资源并请求资源;(4)循环依赖;解决这四个条件之一就能解除死锁,比如要求同时获得所有资源,否则放弃获取的资源,所有资源必须得按照固定顺序获取,一定时间获取不到资源主动或者被动释放资源;
7.Java的线程和操作系统的线程是一样的,和操作系统共用一套线程可以充分利用操作系统的多线程调度机制来提高并发性能,在Java创建一个线程那么操作系统也会创建一个线程;
1.多个线程同时多一个共享变量进行修改,一个线程的操作被另一个线程的操作覆盖,导致结果跟预期不符。例如加法器,多线程下,一个线程的加法操作被其它线程覆盖,最后结果会小于预期
2.写操作加锁,控制共享变量的访问线程(信号量Semaphore),线程之间隔离开(threadLocal),CAS
3.java的synchronized和Lock接口,都是基于操作系统的Mutex Lock实现的。大致思路是,在进入临界区代码前,先尝试能不能获取锁,如果可以获取锁,那就直接进入临界区,此时的这个线程获取到了锁,其它线程只能阻塞,直到这个线程释放锁。
编译后的临界区代码前后,分别有个monitorenter和monitorexit,用于获取和释放对象的monitor,当代码执行到monitorenter,会尝试获取锁,如果获取成功,就进去;如果失败,就阻塞;代码执行到monitorexit的时候,就释放这个锁。
4.乐观锁认为一个共享变量在同一时间,不会有多个线程去修改,乐观锁读取数据时不加锁,提交更新时判断一下数据有没有发生冲突,如果没有就提交;如果有,就采取冲突策略,比如回滚或者重试;
悲观锁在读取数据的时候先加锁,其它线程不能访问,直到修改完提交之后,其它线程才能访问数据;
5.基于乐观锁实现有原子类比如AtomicInteger AtomicReference,底层基于CAS实现,提交数据时比较数据的值和版本号是否发生变化,来判断数据有没有被其它线程修改过
悲观锁实现的有synchronized:对整个代码块加锁,优化过之后引入了锁粗化、自旋锁、锁消除等;Lock接口,具体实现比如ReentrantLock,底层基于AQS和CAS实现,支持响应中断、公平锁和非公平锁;
6.多个线程循环等待对方已经占有并且一直不释放的资源,导致等待死循环。产生死锁有四个条件:1.资源无法同时被多个线程占有;2.线程一直等待,没有设置超时;3.已经被获取的资源,其它线程不能硬抢;4.多个线程循环等待对方释放锁;
解决死锁,可以从这些条件里面入手,比如:设置超时时间,等待超过一段时间就自动放弃;竞争不到资源的线程,可以主动释放自己手上的资源;获取资源的时候按顺序来,打破等待的死循环;通过查看堆栈信息,监控锁的状态
7.操作系统的线程和java线程是1对1的关系,也就是说,每当我们在JVM创建了一个线程,操作系统也会创建一个线程,这样java线程可以利用操作系统的多核处理器来和多线程调度机制来提高并发性能。但是java线程的创建、切换、销毁都需要调用操作系统的API,这个过程的开销比较大,为了减小这种开销,java提供了线程池的机制,来实现对线程的重复利用
1、你是怎么理解线程不安全的?线程不安全会带来哪些问题?(回答指导:采用经典的 i++ 来回答不安全,至于带来什么问题,如果可以距离实际项目那最好了)
线程不安全就是指在多线程环境下,多个线程同时访问和修改共享数据时,可能会数据出现错误或者不一致的情况。比如多个线程同时对一个 int i 变量进行 i++ 时,会出现丢失更新。具体来说,i++ 可以分解为三个动作:读取当前值、对该值加1、将该值写回。这可能会导致多个线程同时读到相同的值并对其加 1,导致最终结果一共只增加 1,而不是每个线程都加 1。同时由于线程之间的执行顺序是不确定的,每次运行时都可能会得到不同的结果,使程序具有不确定性。
项目中有一个用户可以对某个专栏点赞的功能,多个用户同时进行点赞的过程就类似点赞数量++ 的过程,可能会出现 “点赞被吞” 的情况。这时可以对 “点赞” 的操作加锁,也可以直接使用 Java 并发包提供的原子更新类 atomicInteger 类,底层使用 CAS 完成自增操作,效率高。
2、多线程环境下,我们可以怎么解决线程不安全的问题?
最先想到的是可以用 synchronized 关键字为方法或者代码块加锁,保证同一时刻只有一个线程可以访问共享资源。还可以使用 volatile,它是轻量级的 synchronized,能够保证所修饰变量的可见性和有序性。
除了这两个关键字,JDK 还提供了一些线程安全的数据结构,如 ConcurrentHashMap,ConcurrentLinkedQueue 等等,可以直接使用。还有并发包下的原子操作类 atomicInteger 类等,并发工具类 CountDownLatch,Semaphore 类等等,都可以辅助我们实现线程安全。
3、刚才说到了锁,那你觉得锁怎是么实现线程安全?(回答指导:这个得从操作系统角度回答了)
一个简单的锁模型是,在临界区代码前后分别加上加锁和解锁的操作。线程在进入临界区之前会先尝试加锁,如果成功则进入临界区,此时这个线程持有锁。否则当前线程等待,指导持有锁的线程执行完临界区的代码后解锁。
Java 中的 synchronized 实现就跟这个思路类似,它是在编译后的代码前后分别添加 monitorenter 和 monitorexit 指令,JVM 确保某个对象都会对应一个 monitor,当线程执行到 monitorenter 时会尝试获取当前的 monitor 对象,也就是加锁操作。同样的,执行到 monitorexit 指令时会释放这个 monitor,也就是解锁操作。
4、刚才我们说到了锁,乐观锁和悲观锁了解吗?可以说一说他们的区别吗?
乐观锁在读取数据时并不会对数据进行加锁,而是在提交更新时检查是否有其他线程对数据进行了修改。如果没有发生冲突,那么更新成功,否则就要采取一些冲突解决策略,比如回滚或或者重试。
悲观锁则是一种比较保守的策略,它在访问数据之前,会先对资源进行加锁,确保其他线程无法同时访问,必须等待锁释放后才能继续执行。
5、Java 中有些是基于乐观锁实现的,有些是基于悲观锁实现的,你了解到的有哪些呢?
并发包下的原子更新类比如 AtomicInteger 类就是典型的乐观锁,底层使用 CAS 操作实现。
悲观锁有 synchronized,对整个方法或代码块加锁;ReetrantLock,比 synchronized 更加灵活,可以手动控制加锁和解锁的过程;ReetrantReadWriteLock,允许多个线程同时读,但只允许一个线程写。
6、死锁了解吗?可以简单说下死锁以及怎么解决死锁问题的不?
死锁就是:一组互相竞争资源的线程因互相等待,导致永久阻塞的现象。
死锁出现时一定会满足四个条件:第一个是互斥,即共享资源只能被一个线程占用;第二个是占有且等待,即线程在持有一个共享资源,并等待另一个共享资源时,不会释放当前的共享资源;第三个是不可抢占,即任何线程不能抢占其他线程的共享资源;第四个是环路等待,最终形成死锁时,各个线程会形成一个进程到资源的环形链。
因为锁一定是互斥的,所以解决死锁只需破坏以上后三个条件任意一个即可。对于占有且等待,可以让线程加锁时一次性申请所有的资源,这样就不存在线程等待了。对于不可抢占,可以让线程获取不到资源时,主动释放当前已经占有的资源。对于环路等待,可以对资源进行按序申请。也就是说,线程在申请资源时优先申请序号小的,再申请序号大的,这样就不会存在等待环路了。
7、java里面的线程和操作系统的线程一样吗?比如我在Java中创建了一个线程,那么操作系统也会创建一个线程吗?
JDK 1.2 以前采用的是绿色线程,由 JVM 管理和调度,发生在操作系统的用户空间。这对操作系统是透明的,操作系统只能看到进程,而不能看到其中的线程。
这种模式的缺点为,因为操作系统不知道线程的存在,CPU 的时间片是以进程为调度的,如果进程中的某个线程阻塞,则会导致整个进程折射。优点也很明显,可以在不支持多线程的操作系统上实现多线程,并且线程切换只需在用户态就可以实现,免去了用户态到内核态的来回切换。
JDK 1.2 以后 Java 线程就是直接依赖操作系统实现的,是 1:1 的关系。也就是说 Java 中的线程,实质上就是操作系统中的线程。
因此,就目前来看,在 Java 中创建了一个线程,操作系统也会创建一个线程,是对的。