이 기사는 java에 대한 관련 지식을 제공합니다. 주로 Java 동시성에 관련된 문제를 소개하고 몇 가지 문제를 요약하여 모두에게 도움이 되기를 바랍니다.
추천 학습: "java tutorial"
운영 체제의 관점에서 스레드는 CPU 할당의 가장 작은 단위입니다.
마치 우리가 매점에 음식을 사러 갈 때, 병렬성은 여러 창구에 줄을 서고, 여러 아줌마들이 동시에 음식을 내놓는 것을 의미합니다. 이모는 이 사람에게 한 숟가락 주고, 서둘러 한 숟가락 더 줍니다.
스레드에 대해 이야기하려면 먼저 프로세스에 대해 이야기해야 합니다.
운영 체제가 리소스를 할당할 때는 프로세스에 리소스를 할당하지만 CPU 리소스는 상당히 특별합니다. 스레드가 실제로 실행을 위해 CPU를 점유하기 때문입니다. CPU 할당 단위의 기준입니다.
예를 들어 Java에서는 메인 함수를 시작하면 실제로 JVM 프로세스가 시작되는데, 메인 함수가 위치한 스레드는 이 프로세스의 스레드이며, 메인 스레드라고도 합니다.
프로세스에는 여러 스레드가 있습니다. 여러 스레드가 프로세스의 힙 및 메서드 영역 리소스를 공유하지만 각 스레드에는 자체 프로그램 카운터와 스택이 있습니다.
Java에서 스레드를 생성하는 세 가지 주요 방법은 Thread 클래스 상속, Runnable 인터페이스 구현 및 Callable 인터페이스 구현입니다.
public class ThreadTest { /** * 继承Thread类 */ public static class MyThread extends Thread { @Override public void run() { System.out.println("This is child thread"); } } public static void main(String[] args) { MyThread thread = new MyThread(); thread.start(); }}
public class RunnableTask implements Runnable { public void run() { System.out.println("Runnable!"); } public static void main(String[] args) { RunnableTask task = new RunnableTask(); new Thread(task).start(); }}
둘 다 위의 내용은 반환값이 없습니다. 그만한 가치가 있지만 스레드의 실행 결과를 얻으려면 어떻게 해야 할까요?
public class CallerTask implements Callable<string> { public String call() throws Exception { return "Hello,i am running!"; } public static void main(String[] args) { //创建异步任务 FutureTask<string> task=new FutureTask<string>(new CallerTask()); //启动线程 new Thread(task).start(); try { //等待执行完成,并获取返回结果 String result=task.get(); System.out.println(result); } catch (InterruptedException e) { e.printStackTrace(); } catch (ExecutionException e) { e.printStackTrace(); } }}</string></string></string>
JVM이 start 메소드를 실행하면 먼저 스레드가 생성되고, 생성된 새 스레드는 스레드의 run 메소드를 실행하여 멀티스레딩 효과를 얻습니다.
**왜 run() 메소드를 직접 호출할 수 없나요? **Thread의 run() 메서드를 직접 호출하면 run 메서드는 여전히 메인 스레드에서 실행되며 이는 순차 실행과 동일하며 멀티스레딩 효과를 얻지 못합니다.
스레드 대기 및 알림
Object 클래스에는 스레드 대기 및 알림에 사용할 수 있는 몇 가지 함수가 있습니다.
wait(): 스레드 A가 공유 변수의 wait() 메서드를 호출하면 스레드 A가 차단되고 일시 중지되며 다음 상황이 발생할 때만 반환됩니다.
(1) 스레드 A 호출 공유 객체 inform() 또는 informAll() 메서드
(2) 다른 스레드가 스레드 A의 Interrupt() 메서드를 호출하고 스레드 A가 InterruptedException을 발생시키고 반환합니다.
wait(long timeout): 이 메소드에는 wait() 메소드보다 timeout 매개변수가 하나 더 있습니다. 차이점은 스레드 A가 공유 객체의 wait(long timeout) 메소드를 호출하는 경우 If가 없다는 것입니다. 스레드가 지정된 시간 초과(ms) 내에 다른 스레드에 의해 깨어난 경우에도 이 메서드는 시간 초과로 인해 계속 반환됩니다.
wait(long timeout, int nanos), 내부적으로 wait(long timeout) 함수를 호출합니다.
위는 스레드 대기에 대한 방법이며, 스레드를 깨우는 방법에는 주로 두 가지가 있습니다.
Thread 클래스는 대기를 위한 메서드도 제공합니다.
join(): 스레드 A가 thread.join() 문을 실행하는 경우 의미는 다음과 같습니다. 현재 스레드 A는 스레드 스레드가 종료될 때까지 기다립니다.
thread.join()에서 반환되었습니다.
Thread sleep
우선순위 포기
스레드 중단
Java의 스레드 중단은 스레드 간의 협력 모드입니다. 스레드의 중단 플래그를 설정하면 스레드의 실행을 직접 종료할 수 없습니다. 대신 중단된 스레드가 스스로 처리합니다. 중단 상태.
Java에는 6가지 스레드 상태가 있습니다.
State | Description |
---|---|
NEW | 초기 상태: 스레드가 생성되었지만 start() 메서드가 아직 호출되지 않았습니다 |
RUNNABLE | 실행 상태: Java 스레드는 일반적으로 운영 체제에서 준비 및 실행 중이라는 두 가지 상태를 "실행 중"으로 나타냅니다. |
BLOCKED | 차단 상태: 스레드가 잠금으로 차단되었음을 나타냅니다. |
WAITING | Waiting 상태: 스레드가 대기 상태에 들어간다는 것은 현재 스레드가 다른 스레드가 특정 작업(알림 또는 중단)을 수행할 때까지 기다려야 함을 의미합니다. |
TIME_WAITING | 시간 초과 대기 state: WAITIND와 다른 상태로, 지정된 시간에 실행 가능 |
TERMINATED | 자체 반환되는 종료 상태: 현재 스레드의 실행이 완료되었음을 나타냄 |
자체 수명 주기에서 스레드는 고정된 상태가 아니지만 코드가 실행될 때 다른 상태 간에 전환됩니다. 그림에 표시된 대로 Java 스레드 상태가 변경됩니다.
멀티스레딩을 사용하는 목적은 CPU를 최대한 활용하는 것이지만 동시성은 실제로 여러 스레드를 처리하기 위한 하나의 CPU라는 것을 알고 있습니다.
사용자가 여러 스레드가 동시에 실행되고 있다는 느낌을 주기 위해 CPU 리소스 할당은 타임 슬라이스 회전을 채택합니다. 즉, 각 스레드에 타임 슬라이스가 할당되고 스레드는 CPU를 점유하여 내에서 작업을 수행합니다. 시간 조각. 스레드가 해당 시간 조각 사용을 마치면 준비 상태가 되며 다른 스레드가 CPU를 점유하게 됩니다. 이것이 컨텍스트 전환입니다.
Java의 스레드는 데몬 스레드(데몬 스레드)와 사용자 스레드(사용자 스레드)라는 두 가지 범주로 나뉩니다.
JVM이 시작되면 메인 함수가 호출됩니다. 메인 함수가 위치한 프로세스는 사용자 스레드입니다. 실제로 가비지 수집 스레드와 같은 많은 데몬 스레드도 JVM 내부에서 시작됩니다.
그럼 데몬 스레드와 사용자 스레드의 차이점은 무엇인가요? 차이점 중 하나는 데몬이 아닌 마지막 스레드가 워프될 때 현재 데몬 스레드가 있는지 여부에 관계없이 JVM이 정상적으로 종료된다는 점입니다. 즉, 데몬 스레드가 종료되는지 여부가 JVM 종료에 영향을 주지 않는다는 의미입니다. 즉, 하나의 사용자 스레드가 종료되지 않는 한 JVM은 정상적인 상황에서 종료되지 않습니다.
휘발성 키워드는 필드(멤버 변수)를 수정하는 데 사용할 수 있습니다. 이는 변수에 대한 모든 액세스가 공유 메모리에서 얻어야 함을 프로그램에 알립니다. 변경 사항은 동기적으로 공유 메모리로 다시 플러시되어야 하며, 이는 모든 스레드의 변수 액세스 가시성을 보장합니다.
Synchronized 키워드는 메서드를 수정하는 데 사용되거나 동기화된 블록 형태로 사용될 수 있습니다. 이는 주로 여러 스레드가 메서드 또는 동기화된 블록에서 동시에 하나의 스레드만 가질 수 있도록 보장합니다. 성별 및 독점성에 액세스할 때 표시됩니다.
Java에 내장된 대기/알림 메커니즘(wait()/notify())을 사용하면 한 스레드가 객체 값을 수정하고 다른 스레드가 변경 사항을 감지한다는 것을 인식할 수 있습니다. 그런 다음 그에 따라 작업에 응답합니다.
파이프 입/출력 스트림과 일반 파일 입/출력 스트림 또는 네트워크 입/출력 스트림의 차이점은 주로 스레드 간 데이터 전송에 사용된다는 점입니다. 매체는 메모리입니다. .
파이프라인 입력/출력 스트림에는 주로 PipedOutputStream, PipedInputStream, PipedReader 및 PipedWriter의 네 가지 특정 구현이 포함됩니다. 처음 두 개는 바이트 지향이고 후자 두 개는 문자 지향입니다.
스레드 A가 thread.join() 문을 실행하는 경우 의미는 다음과 같습니다. 현재 스레드 A는 thread.join()에서 반환되기 전에 스레드 스레드가 종료될 때까지 기다립니다. . . Join() 메서드 외에도 Thread Thread는 시간 초과 특성이 있는 두 가지 메서드인 Join(long millis) 및 Join(long millis, int nanos)도 제공합니다.
ThreadLocal 또는 스레드 변수는 ThreadLocal 개체를 키로, 모든 개체를 값으로 사용하는 저장 구조입니다. 이 구조는 스레드에 연결됩니다. 즉, 스레드는 ThreadLocal 개체를 기반으로 이 스레드에 바인딩된 값을 쿼리할 수 있습니다.
set(T) 메소드를 통해 값을 설정한 후, 현재 스레드에서 get() 메소드를 통해 원래 설정된 값을 얻을 수 있습니다.
멀티스레딩과 관련하여 대체 인쇄, 은행 송금, 생산 및 소비 모델 등과 같은 필기 시험 문제가 있을 가능성이 높습니다. 나중에 Laosan은 일반적인 멀티스레딩을 검토하기 위해 별도의 문제를 발행할 예정입니다. 스레드 필기 시험 문제.
ThreadLocal은 실제로 많은 응용 시나리오를 가지고 있지는 않지만, 멀티스레딩, 데이터 구조, JVM을 포함하여 수천 번 포격을 받은 인터뷰 베테랑입니다. 이겨야 합니다.
ThreadLocal은 스레드 지역 변수입니다. ThreadLocal 변수를 생성하면 이 변수에 액세스하는 각 스레드는 이 변수의 로컬 복사본을 갖게 됩니다. 여러 스레드가 이 변수를 작동할 때 실제로는 자체 로컬 메모리에서 변수를 작동하므로 스레드 격리 기능이 달성됩니다. 안전 문제.
创建了一个ThreadLoca变量localVariable,任何一个线程都能并发访问localVariable。
//创建一个ThreadLocal变量public static ThreadLocal<string> localVariable = new ThreadLocal();</string>
线程可以在任何地方使用localVariable,写入变量。
localVariable.set("鄙人三某”);
线程在任何地方读取的都是它写入的变量。
localVariable.get();
有用到过的,用来做用户信息上下文的存储。
我们的系统应用是一个典型的MVC架构,登录后的用户每次访问接口,都会在请求头中携带一个token,在控制层可以根据这个token,解析出用户的基本信息。那么问题来了,假如在服务层和持久层都要用到用户信息,比如rpc调用、更新用户获取等等,那应该怎么办呢?
一种办法是显式定义用户相关的参数,比如账号、用户名……这样一来,我们可能需要大面积地修改代码,多少有点瓜皮,那该怎么办呢?
这时候我们就可以用到ThreadLocal,在控制层拦截请求把用户信息存入ThreadLocal,这样我们在任何一个地方,都可以取出ThreadLocal中存的用户数据。
很多其它场景的cookie、session等等数据隔离也都可以通过ThreadLocal去实现。
我们常用的数据库连接池也用到了ThreadLocal:
我们看一下ThreadLocal的set(T)方法,发现先获取到当前线程,再获取ThreadLocalMap
,然后把元素存到这个map中。
public void set(T value) { //获取当前线程 Thread t = Thread.currentThread(); //获取ThreadLocalMap ThreadLocalMap map = getMap(t); //讲当前元素存入map if (map != null) map.set(this, value); else createMap(t, value); }
ThreadLocal实现的秘密都在这个ThreadLocalMap
了,可以Thread类中定义了一个类型为ThreadLocal.ThreadLocalMap
的成员变量threadLocals
。
public class Thread implements Runnable { //ThreadLocal.ThreadLocalMap是Thread的属性 ThreadLocal.ThreadLocalMap threadLocals = null;}
ThreadLocalMap既然被称为Map,那么毫无疑问它是
static class Entry extends WeakReference<threadlocal>> { /** The value associated with this ThreadLocal. */ Object value; //节点类 Entry(ThreadLocal> k, Object v) { //key赋值 super(k); //value赋值 value = v; } }</threadlocal>
这里的节点,key可以简单低视作ThreadLocal,value为代码中放入的值,当然实际上key并不是ThreadLocal本身,而是它的一个弱引用,可以看到Entry的key继承了 WeakReference(弱引用),再来看一下key怎么赋值的:
public WeakReference(T referent) { super(referent); }
key的赋值,使用的是WeakReference的赋值。
所以,怎么回答ThreadLocal原理?要答出这几个点:
我们先来分析一下使用ThreadLocal时的内存,我们都知道,在JVM中,栈内存线程私有,存储了对象的引用,堆内存线程共享,存储了对象实例。
所以呢,栈中存储了ThreadLocal、Thread的引用,堆中存储了它们的具体实例。
ThreadLocalMap中使用的 key 为 ThreadLocal 的弱引用。
“弱引用:只要垃圾回收机制一运行,不管JVM的内存空间是否充足,都会回收该对象占用的内存。”
那么现在问题就来了,弱引用很容易被回收,如果ThreadLocal(ThreadLocalMap的Key)被垃圾回收器回收了,但是ThreadLocalMap生命周期和Thread是一样的,它这时候如果不被回收,就会出现这种情况:ThreadLocalMap的key没了,value还在,这就会造成了内存泄漏问题。
那怎么解决内存泄漏问题呢?
很简单,使用完ThreadLocal后,及时调用remove()方法释放内存空间。
ThreadLocallocalVariable = new ThreadLocal();try { localVariable.set("鄙人三某”); ……} finally { localVariable.remove();}
那为什么key还要设计成弱引用?
key设计成弱引用同样是为了防止内存泄漏。
假如key被设计成强引用,如果ThreadLocal Reference被销毁,此时它指向ThreadLoca的强引用就没有了,但是此时key还强引用指向ThreadLoca,就会导致ThreadLocal不能被回收,这时候就发生了内存泄漏的问题。
ThreadLocalMap虽然被叫做Map,其实它是没有实现Map接口的,但是结构还是和HashMap比较类似的,主要关注的是两个要素:元素数组
和散列方法
。
元素数组
一个table数组,存储Entry类型的元素,Entry是ThreaLocal弱引用作为key,Object作为value的结构。
private Entry[] table;
散列方法
散列方法就是怎么把对应的key映射到table数组的相应下标,ThreadLocalMap用的是哈希取余法,取出key的threadLocalHashCode,然后和table数组长度减一&运算(相当于取余)。
int i = key.threadLocalHashCode & (table.length - 1);
这里的threadLocalHashCode计算有点东西,每创建一个ThreadLocal对象,它就会新增0x61c88647
,这个值很特殊,它是斐波那契数 也叫 黄金分割数。hash
增量为 这个数字,带来的好处就是 hash
分布非常均匀。
private static final int HASH_INCREMENT = 0x61c88647; private static int nextHashCode() { return nextHashCode.getAndAdd(HASH_INCREMENT); }
我们可能都知道HashMap使用了链表来解决冲突,也就是所谓的链地址法。
ThreadLocalMap没有使用链表,自然也不是用链地址法来解决冲突了,它用的是另外一种方式——开放定址法。开放定址法是什么意思呢?简单来说,就是这个坑被人占了,那就接着去找空着的坑。
如上图所示,如果我们插入一个value=27的数据,通过 hash计算后应该落入第 4 个槽位中,而槽位 4 已经有了 Entry数据,而且Entry数据的key和当前不相等。此时就会线性向后查找,一直找到 Entry为 null的槽位才会停止查找,把元素放到空的槽中。
在get的时候,也会根据ThreadLocal对象的hash值,定位到table中的位置,然后判断该槽位Entry对象中的key是否和get的key一致,如果不一致,就判断下一个位置。
在ThreadLocalMap.set()方法的最后,如果执行完启发式清理工作后,未清理到任何数据,且当前散列数组中Entry
的数量已经达到了列表的扩容阈值(len*2/3)
,就开始执行rehash()
逻辑:
if (!cleanSomeSlots(i, sz) && sz >= threshold) rehash();
再着看rehash()具体实现:这里会先去清理过期的Entry,然后还要根据条件判断size >= threshold - threshold / 4
也就是size >= threshold* 3/4
来决定是否需要扩容。
private void rehash() { //清理过期Entry expungeStaleEntries(); //扩容 if (size >= threshold - threshold / 4) resize();}//清理过期Entryprivate void expungeStaleEntries() { Entry[] tab = table; int len = tab.length; for (int j = 0; j <p>接着看看具体的<code>resize()</code>方法,扩容后的<code>newTab</code>的大小为老数组的两倍,然后遍历老的table数组,散列方法重新计算位置,开放地址解决冲突,然后放到新的<code>newTab</code>,遍历完成之后,<code>oldTab</code>中所有的<code>entry</code>数据都已经放入到<code>newTab</code>中了,然后table引用指向<code>newTab</code></p><p><img src="https://img.php.cn/upload/article/000/000/067/6137d48077cbb320beee2007e8763d69-16.png" alt="Java 동시성 관련 지식 요약"></p><p>具体代码:</p><p><img src="https://img.php.cn/upload/article/000/000/067/85310a4f8d2bb86283cd76fb2542424d-17.png" alt="ThreadLocalMap resize"></p><h2>17.父子线程怎么共享数据?</h2><p>父线程能用ThreadLocal来给子线程传值吗?毫无疑问,不能。那该怎么办?</p><p>这时候可以用到另外一个类——<code>InheritableThreadLocal</code>。</p><p>使用起来很简单,在主线程的InheritableThreadLocal实例设置值,在子线程中就可以拿到了。</p><pre class="brush:php;toolbar:false">public class InheritableThreadLocalTest { public static void main(String[] args) { final ThreadLocal threadLocal = new InheritableThreadLocal(); // 主线程 threadLocal.set("不擅技术"); //子线程 Thread t = new Thread() { @Override public void run() { super.run(); System.out.println("鄙人三某 ," + threadLocal.get()); } }; t.start(); }}
那原理是什么呢?
原理很简单,在Thread类里还有另外一个变量:
ThreadLocal.ThreadLocalMap inheritableThreadLocals = null;
在Thread.init的时候,如果父线程的inheritableThreadLocals
不为空,就把它赋给当前线程(子线程)的inheritableThreadLocals
。
if (inheritThreadLocals && parent.inheritableThreadLocals != null) this.inheritableThreadLocals = ThreadLocal.createInheritedMap(parent.inheritableThreadLocals)
Java 동시성 관련 지식 요약(Java Memory Model,JMM),是一种抽象的模型,被定义出来屏蔽各种硬件和操作系统的内存访问差异。
JMM定义了线程和主内存之间的抽象关系:线程之间的共享变量存储在主内存
(Main Memory)中,每个线程都有一个私有的本地内存
(Local Memory),本地内存中存储了该线程以读/写共享变量的副本。
Java 동시성 관련 지식 요약的抽象图:
本地内存是JMM的 一个抽象概念,并不真实存在。它其实涵盖了缓存、写缓冲区、寄存器以及其他的硬件和编译器优化。
图里面的是一个双核 CPU 系统架构 ,每个核有自己的控制器和运算器,其中控制器包含一组寄存器和操作控制器,运算器执行算术逻辅运算。每个核都有自己的一级缓存,在有些架构里面还有一个所有 CPU 共享的二级缓存。 那么 Java 内存模型里面的工作内存,就对应这里的 Ll 缓存或者 L2 缓存或者 CPU 寄存器。
原子性、有序性、可见性是并发编程中非常重要的基础概念,JMM的很多技术都是围绕着这三大特性展开。
分析下面几行代码的原子性?
int i = 2;int j = i;i++;i = i + 1;
原子性、可见性、有序性都应该怎么保证呢?
synchronized
。volatile
关键字来保证可见性的,除此之外,final
和synchronized
也能保证可见性。synchronized
或者volatile
都可以保证多线程之间操作的有序性。在执行程序时,为了提高性能,编译器和处理器常常会对指令做重排序。重排序分3种类型。
从Java源代码到最终实际执行的指令序列,会分别经历下面3种重排序,如图:
我们比较熟悉的双重校验单例模式就是一个经典的指令重排的例子,Singleton instance=new Singleton();
对应的JVM指令分为三步:分配内存空间–>初始化对象—>对象指向分配的内存空间,但是经过了编译器的指令重排序,第二步和第三步就可能会重排序。
JMM属于语言级的内存模型,它确保在不同的编译器和不同的处理器平台之上,通过禁止特定类型的编译器重排序和处理器重排序,为程序员提供一致的内存可见性保证。
指令重排也是有一些限制的,有两个规则happens-before
和as-if-serial
来约束。
happens-before的定义:
happens-before和我们息息相关的有六大规则:
as-if-serial语义的意思是:不管怎么重排序(编译器和处理器为了提高并行度),单线程程序的执行结果不能被改变。编译器、runtime和处理器都必须遵守as-if-serial语义。
为了遵守as-if-serial语义,编译器和处理器不会对存在数据依赖关系的操作做重排序,因为这种重排序会改变执行结果。但是,如果操作之间不存在数据依赖关系,这些操作就可能被编译器和处理器重排序。为了具体说明,请看下面计算圆面积的代码示例。
double pi = 3.14; // Adouble r = 1.0; // B double area = pi * r * r; // C
上面3个操作的数据依赖关系:
A和C之间存在数据依赖关系,同时B和C之间也存在数据依赖关系。因此在最终执行的指令序列中,C不能被重排序到A和B的前面(C排到A和B的前面,程序的结果将会被改变)。但A和B之间没有数据依赖关系,编译器和处理器可以重排序A和B之间的执行顺序。
所以最终,程序可能会有两种执行顺序:
as-if-serial语义把单线程程序保护了起来,遵守as-if-serial语义的编译器、runtime和处理器共同编织了这么一个“楚门的世界”:单线程程序是按程序的“顺序”来执行的。as- if-serial语义使单线程情况下,我们不需要担心重排序的问题,可见性的问题。
volatile有两个作用,保证可见性和有序性。
volatile怎么保证可见性的呢?
相比synchronized的加锁方式来解决共享变量的内存可见性问题,volatile就是更轻量的选择,它没有上下文切换的额外开销成本。
volatile可以确保对某个变量的更新对其他线程马上可见,一个变量被声明为volatile 时,线程在写入变量时不会把值缓存在寄存器或者其他地方,而是会把值刷新回主内存 当其它线程读取该共享变量 ,会从主内存重新获取最新值,而不是使用当前线程的本地内存中的值。
例如,我们声明一个 volatile 变量 volatile int x = 0,线程A修改x=1,修改完之后就会把新的值刷新回主内存,线程B读取x的时候,就会清空本地内存变量,然后再从主内存获取最新值。
volatile怎么保证有序性的呢?
重排序可以分为编译器重排序和处理器重排序,valatile保证有序性,就是通过分别限制这两种类型的重排序。
为了实现volatile的内存语义,编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。
StoreStore
屏障StoreLoad
屏障LoadLoad
屏障LoadStore
屏障synchronized经常用的,用来保证代码的原子性。
synchronized主要有三种用法:
synchronized void method() { //业务代码}
修饰静态方法:也就是给当前类加锁,会作⽤于类的所有对象实例 ,进⼊同步代码前要获得当前 class 的锁。因为静态成员不属于任何⼀个实例对象,是类成员( static 表明这是该类的⼀个静态资源,不管 new 了多少个对象,只有⼀份)。
如果⼀个线程 A 调⽤⼀个实例对象的⾮静态 synchronized ⽅法,⽽线程 B 需要调⽤这个实例对象所属类的静态 synchronized ⽅法,是允许的,不会发⽣互斥现象,因为访问静态 synchronized ⽅法占⽤的锁是当前类的锁,⽽访问⾮静态 synchronized ⽅法占⽤的锁是当前实例对象锁。
synchronized void staic method() { //业务代码}
synchronized(this) { //业务代码}
synchronized是怎么加锁的呢?
我们使用synchronized的时候,发现不用自己去lock和unlock,是因为JVM帮我们把这个事情做了。
synchronized修饰代码块时,JVM采用monitorenter
、monitorexit
两个指令来实现同步,monitorenter
指令指向同步代码块的开始位置, monitorexit
指令则指向同步代码块的结束位置。
反编译一段synchronized修饰代码块代码,javap -c -s -v -l SynchronizedDemo.class
,可以看到相应的字节码指令。
Java 동시성 관련 지식 요약时,JVM采用ACC_SYNCHRONIZED
标记符来实现同步,这个标识指明了该方法是一个同步方法。
同样可以写段代码反编译看一下。
synchronized锁住的是什么呢?
monitorenter、monitorexit或者ACC_SYNCHRONIZED都是基于Monitor实现的。
实例对象结构里有对象头,对象头里面有一块结构叫Mark Word,Mark Word指针指向了monitor。
所谓的Monitor其实是一种同步工具,也可以说是一种同步机制。在Java虚拟机(HotSpot)中,Monitor是由ObjectMonitor实现的,可以叫做内部锁,或者Monitor锁。
ObjectMonitor的工作原理:
ObjectMonitor() { _header = NULL; _count = 0; // 记录线程获取锁的次数 _waiters = 0, _recursions = 0; //锁的重入次数 _object = NULL; _owner = NULL; // 指向持有ObjectMonitor对象的线程 _WaitSet = NULL; // 处于wait状态的线程,会被加入到_WaitSet _WaitSetLock = 0 ; _Responsible = NULL ; _succ = NULL ; _cxq = NULL ; FreeNext = NULL ; _EntryList = NULL ; // 处于等待锁block状态的线程,会被加入到该列表 _SpinFreq = 0 ; _SpinClock = 0 ; OwnerIsThread = 0 ; }
可以类比一个去医院就诊的例子[18]:
首先,患者在门诊大厅前台或自助挂号机进行挂号;
随后,挂号结束后患者找到对应的诊室就诊:
就诊结束后,走出就诊室,候诊室的下一位候诊患者进入就诊室。
这个过程就和Monitor机制比较相似:
어떤 동기화가 잠겨 있는지 알 수 있습니다:
동기화는 어떻게 가시성을 보장하나요?
동기화는 어떻게 질서를 보장하나요?
동기화된 코드 블록은 배타적이며 한 번에 하나의 스레드만 소유할 수 있으므로 동기화는 코드가 동시에 단일 스레드에서 실행되도록 보장합니다.
직렬 의미론의 존재로 인해 단일 스레드 프로그램은 최종 결과가 올바른지 확인할 수 있지만 지침이 재정렬되지 않을 것이라는 보장은 없습니다.
따라서 동기화에 의해 보장되는 순서는 명령 재정렬을 방지하기 위한 순서가 아니라 실행 결과의 순서입니다.
동기화는 어떻게 재진입을 달성하나요?
synchronized는 재진입 잠금입니다. 즉, 스레드가 보유하고 있는 개체 잠금의 중요한 리소스를 두 번 요청할 수 있습니다.
객체를 잠글 때 카운터가 있습니다. 스레드가 잠금을 획득한 횟수를 기록합니다. 해당 코드 블록을 실행한 후 카운터가 지워지고 잠금이 해제될 때까지 카운터는 -1입니다.
이유는 재진입이기 때문이죠. 이는 동기화된 잠금 개체에 카운터가 있기 때문입니다. 이 카운터는 스레드가 잠금을 획득한 후 +1을 계산하고 스레드가 실행을 완료하면 잠금을 해제하기 위해 지워질 때까지 -1을 계산합니다.
잠금 해제하고 업그레이드하려면 먼저 다양한 잠금 상태가 무엇인지 알아야 합니다. 이 상태는 무엇을 의미합니까?
Java 객체 헤더에는 Mark Word
mark 필드라는 구조가 있습니다. 이 구조는 잠금 상태가 변경됨에 따라 변경됩니다.
64비트 가상 머신 Mark Word는 64비트입니다. 상태 변경을 살펴보겠습니다.
Mark Word는 해시 코드, GC 생성 기간, 잠금 상태 등 개체 자체의 실행 데이터를 저장합니다. 플래그 및 바이어스 타임스탬프(Epoch) 등
어떤 최적화가 동기화되었나요?
JDK1.6 이전에는 ObjectMonitor의 Enter 및 Exit를 직접 호출하는 동기화 구현을 heavyweight lock이라고 했습니다. HotSpot 가상 머신 개발팀은 JDK6부터 시작하여 적응형 스핀, 잠금 제거, 잠금 조정, 경량 잠금 및 바이어스 잠금과 같은 최적화 전략을 추가하는 등 Java에서 잠금을 최적화하여 동기화 성능을 향상시켰습니다.
바이어스 잠금: 경쟁이 없는 경우 현재 스레드 포인터는 마크 워드에만 저장되고 CAS 작업은 수행되지 않습니다.
경량 잠금: 멀티 스레드 경쟁이 없는 경우 무거운 잠금에 비해 운영 체제 뮤텍스로 인한 성능 소비가 줄어듭니다. 그러나 잠금 경쟁이 있는 경우 뮤텍스 자체의 오버헤드 외에도 CAS 작업의 추가 오버헤드도 있습니다.
스핀 잠금: 불필요한 CPU 컨텍스트 전환을 줄입니다. 경량 자물쇠가 중량 자물쇠로 업그레이드되면 스핀 잠금 방식이 사용됩니다
자물쇠 조대화: 여러 번의 연속 잠금 및 잠금 해제 작업을 함께 연결하여 더 큰 범위의 잠금으로 확장합니다.
잠금 제거: 가상 머신 JIT(Just-In-Time) 컴파일러가 실행 중일 때 일부 코드에서 동기화가 필요하지만 공유 데이터 경쟁이 있을 가능성이 없는 것으로 감지되는 잠금을 제거합니다.
잠금 업그레이드 과정은 어떻게 되나요?
잠금 업그레이드 방향: 잠금 없음 –> 바이어스 잠금 –> 경량 잠금 –> 중량 잠금, 이 방향은 기본적으로 되돌릴 수 없습니다.
업그레이드 과정을 살펴보겠습니다:
바이어스 잠금 획득:
업그레이드가 수행됩니다---T 스레드가 여전히 동기화 코드 블록에 있으면 바이어스 잠금이 수행됩니다. T 스레드는 경량 잠금으로 업그레이드됩니다.
현재 스레드는 경량 잠금 상태에서 잠금 획득 단계를 수행합니다. 상태가 임계값 40에 도달하면 일괄 취소가 수행됩니다.업데이트가 실패하면 jvm은 먼저 MarkWord 객체가 현재 스레드 스택 프레임의 잠금 레코드를 가리키는지 확인합니다. 그렇다면 '5'를 실행하고, 그렇지 않으면 '4'
를 실행하여 잠금 재진입을 나타냅니다. 현재 스레드 스택 프레임에 대한 기록 잠금 첫 번째 부분(Displaced Mark Word)은 null이고 재진입 카운터 역할을 하는 Mark Word의 잠금 개체를 가리킵니다.28. 동기화와 ReentrantLock의 차이점에 대해 이야기해 주세요.
잠금 구현, 기능적 특성, 성능 등 여러 차원에서 이 질문에 답할 수 있습니다.
잠금 구현:동기화는 JVM 기반으로 구현되는 Java 언어의 키워드입니다. ReentrantLock은 JDK의 API 레벨을 기반으로 구현됩니다. (보통 lock() 및 Unlock() 메소드는 try/finally 문 블록과 결합됩니다.)
先简单了解一下CLH:Craig、Landin and Hagersten 队列,是 单向链表实现的队列。申请线程只在本地变量上自旋,它不断轮询前驱的状态,如果发现 前驱节点释放了锁就结束自旋
AQS 中的队列是 CLH 变体的虚拟双向队列,通过将每条请求共享资源的线程封装成一个节点来实现锁的分配:
AQS 中的 CLH 变体等待队列拥有以下特性:
ps:AQS源码里面有很多细节可问,建议有时间好好看看AQS源码。
ReentrantLock 是可重入的独占锁,只能有一个线程可以获取该锁,其它获取该锁的线程会被阻塞而被放入该锁的阻塞队列里面。
看看ReentrantLock的加锁操作:
// 创建非公平锁 ReentrantLock lock = new ReentrantLock(); // 获取锁操作 lock.lock(); try { // 执行代码逻辑 } catch (Exception ex) { // ... } finally { // 解锁操作 lock.unlock(); }
new ReentrantLock()
构造函数默认创建的是非公平锁 NonfairSync。
公平锁 FairSync
非公平锁 NonfairSync
默认创建的对象lock()的时候:
new ReentrantLock()
构造函数默认创建的是非公平锁 NonfairSync
public ReentrantLock() { sync = new NonfairSync();}
同时也可以在创建锁构造函数中传入具体参数创建公平锁 FairSync
ReentrantLock lock = new ReentrantLock(true);--- ReentrantLock// true 代表公平锁,false 代表非公平锁public ReentrantLock(boolean fair) { sync = fair ? new FairSync() : new NonfairSync();}
FairSync、NonfairSync 代表公平锁和非公平锁,两者都是 ReentrantLock 静态内部类,只不过实现不同锁语义。
非公平锁和公平锁的两处不同:
상대적으로 말하면 불공정 잠금은 처리량이 상대적으로 크기 때문에 성능이 더 좋습니다. 물론 불공정한 잠금은 잠금을 획득하는 시간을 더욱 불확실하게 만들어 차단 대기열의 스레드가 오랫동안 고갈될 수 있습니다.
CAS는 CompareAndSwap이라고 하며 주로 프로세서 명령어를 사용하여 작업의 원자적 특성을 보장합니다.
CAS 명령어에는 공유 변수의 메모리 주소 A, 예상 값 B, 공유 변수의 새 값 C 등 3개의 매개변수가 포함되어 있습니다.
메모리의 주소 A의 값이 B와 동일한 경우에만 메모리의 주소 A의 값이 새 값 C로 업데이트될 수 있습니다. CPU 명령어로서 CAS 명령어 자체가 원자성을 보장할 수 있습니다.
CAS의 세 가지 고전적인 문제:
동시 환경에서는 초기 조건이 A라고 가정하고, 데이터를 수정했을 때, A로 판명되면 수정이 수행됩니다. 그러나 당신이 보는 것은 A이지만, A는 B로 변했고, B는 다시 A로 변했을 수도 있습니다. 이때 A는 더 이상 다른 A가 아닙니다. 데이터 수정에 성공하더라도 문제가 발생할 수 있습니다.
ABA 문제를 해결하는 방법은 무엇입니까?
변수를 수정할 때마다 이 변수의 버전 번호에 1을 추가합니다. 이런 식으로 A->B->A만 적용하면 됩니다. A의 값은 변하지 않지만, version 번호가 변경되었습니다. 버전 번호를 다시 판단해 보면 A가 이때 변경된 것을 알 수 있습니다. 낙관적 잠금의 버전 번호를 참조하면 이 접근 방식을 통해 데이터에 대한 실제 테스트를 수행할 수 있습니다.
Java는 AtomicStampReference 클래스를 제공합니다. 해당 클래스의 CompareAndSet 메서드는 먼저 현재 개체 참조 값이 예상 참조 값과 같은지 확인하고, 현재 스탬프(Stamp) 플래그가 예상 플래그와 같은지 여부를 모두 확인합니다. 및 스탬프는 원자적으로 수행됩니다. 스탬프 플래그의 값은 지정된 업데이트 값으로 업데이트됩니다.
Spin CAS가 성공하지 못한 채 루프에서 계속 실행되면 CPU에 매우 큰 실행 오버헤드가 발생하게 됩니다.
루프 성능 오버헤드 문제를 해결하는 방법은 무엇입니까?
Java에서는 스핀 CAS를 사용하는 곳이 많으며, 특정 횟수를 초과하면 스핀 횟수에 제한이 있습니다.
CAS는 하나의 변수에 대한 연산의 원자성을 보장합니다. 여러 변수가 연산되는 경우 CAS는 현재 연산의 원자성을 직접 보장할 수 없습니다.
하나의 변수만 보장할 수 있는 원자 연산 문제를 어떻게 해결할 수 있나요?
프로그램이 변수를 업데이트할 때 여러 스레드가 동시에 변수를 업데이트하면 예상치 못한 값을 얻을 수 있습니다. 예를 들어 변수 i=1, 스레드 A가 i+1을 업데이트하고 스레드 B도 i+를 업데이트합니다. 1. 두 스레드 이후 작업 후 i는 3이 아니라 2와 같을 수 있습니다. 스레드 A와 B는 변수 i를 업데이트할 때 i를 모두 1로 가져오므로 이는 스레드에 안전하지 않은 업데이트 작업입니다. 일반적으로 이 문제를 해결하기 위해 동기화를 사용하면 여러 스레드가 변수 i를 동시에 업데이트하지 않도록 할 수 있습니다. .
사실 이 외에도 더 가벼운 옵션이 있습니다. Java는 JDK 1.5부터 java.util.concurrent.atomic 패키지를 제공하여 이 패키지의 원자적 연산 클래스를 통해 간단한 사용법, 고성능, 스레드로부터 안전한 방법을 제공합니다. 변수를 업데이트합니다.
변수의 종류가 많기 때문에 Atomic 패키지에는 총 13개의 클래스가 제공되는데, 이는 원자 업데이트 기본 유형, 원자 업데이트 배열, 원자 업데이트 참조 및 원자 업데이트 속성의 4가지 유형의 원자 업데이트 방법에 속합니다( 필드).
Atomic 패키지의 클래스는 기본적으로 Unsafe를 사용하여 구현된 래퍼 클래스입니다.
使用原子的方式更新基本类型,Atomic包提供了以下3个类:
AtomicBoolean:原子更新布尔类型。
AtomicInteger:原子更新整型。
AtomicLong:原子更新长整型。
通过原子的方式更新数组里的某个元素,Atomic包提供了以下4个类:
AtomicIntegerArray:原子更新整型数组里的元素。
AtomicLongArray:原子更新长整型数组里的元素。
AtomicReferenceArray:原子更新引用类型数组里的元素。
AtomicIntegerArray类主要是提供原子的方式更新数组里的整型
原子更新基本类型的AtomicInteger,只能更新一个变量,如果要原子更新多个变量,就需要使用这个原子更新引用类型提供的类。Atomic包提供了以下3个类:
AtomicReference:原子更新引用类型。
AtomicReferenceFieldUpdater:原子更新引用类型里的字段。
AtomicMarkableReference:原子更新带有标记位的引用类型。可以原子更新一个布尔类型的标记位和引用类型。构造方法是AtomicMarkableReference(V initialRef,boolean initialMark)。
如果需原子地更新某个类里的某个字段时,就需要使用原子更新字段类,Atomic包提供了以下3个类进行原子字段更新:
一句话概括:使用CAS实现。
以AtomicInteger的添加方法为例:
public final int getAndIncrement() { return unsafe.getAndAddInt(this, valueOffset, 1); }
通过Unsafe
类的实例来进行添加操作,来看看具体的CAS操作:
public final int getAndAddInt(Object var1, long var2, int var4) { int var5; do { var5 = this.getIntVolatile(var1, var2); } while(!this.compareAndSwapInt(var1, var2, var5, var5 + var4)); return var5; }
compareAndSwapInt 是一个native方法,基于CAS来操作int类型变量。其它的Java 동시성 관련 지식 요약基本都是大同小异。
死锁是指两个或两个以上的线程在执行过程中,因争夺资源而造成的互相等待的现象,在无外力作用的情况下,这些线程会一直相互等待而无法继续运行下去。
那么为什么会产生死锁呢? 死锁的产生必须具备以下四个条件:
该如何避免死锁呢?答案是至少破坏死锁发生的一个条件。
其中,互斥这个条件我们没有办法破坏,因为用锁为的就是互斥。不过其他三个条件都是有办法破坏掉的,到底如何做呢?
对于“请求并持有”这个条件,可以一次性请求所有的资源。
对于“不可剥夺”这个条件,占用部分资源的线程进一步申请其他资源时,如果申请不到,可以主动释放它占有的资源,这样不可抢占这个条件就破坏掉了。
对于“环路等待”这个条件,可以靠按序申请资源来预防。所谓按序申请,是指资源是有线性顺序的,申请的时候可以先申请资源序号小的,再申请资源序号大的,这样线性化后就不存在环路了。
可以使用jdk自带的命令行工具排查:
基本就可以看到死锁的信息。
还可以利用图形化工具,比如JConsole。出现线程死锁以后,点击JConsole线程面板的检测到死锁
按钮,将会看到线程的死锁信息。
CountDownLatch,倒计数器,有两个常见的应用场景[18]:
场景1:协调子线程结束动作:等待所有子线程运行结束
CountDownLatch允许一个或多个线程等待其他线程完成操作。
例如,我们很多人喜欢玩的王者荣耀,开黑的时候,得等所有人都上线之后,才能开打。
CountDownLatch模仿这个场景(参考[18]):
创建大乔、兰陵王、安其拉、哪吒和铠等五个玩家,主线程必须在他们都完成确认后,才可以继续运行。
在这段代码中,new CountDownLatch(5)
用户创建初始的latch数量,各玩家通过countDownLatch.countDown()
完成状态确认,主线程通过countDownLatch.await()
等待。
public static void main(String[] args) throws InterruptedException { CountDownLatch countDownLatch = new CountDownLatch(5); Thread 大乔 = new Thread(countDownLatch::countDown); Thread 兰陵王 = new Thread(countDownLatch::countDown); Thread 安其拉 = new Thread(countDownLatch::countDown); Thread 哪吒 = new Thread(countDownLatch::countDown); Thread 铠 = new Thread(() -> { try { // 稍等,上个卫生间,马上到... Thread.sleep(1500); countDownLatch.countDown(); } catch (InterruptedException ignored) {} }); 大乔.start(); 兰陵王.start(); 安其拉.start(); 哪吒.start(); 铠.start(); countDownLatch.await(); System.out.println("所有玩家已经就位!"); }
场景2. 协调子线程开始动作:统一各线程动作开始的时机
王者游戏中也有类似的场景,游戏开始时,各玩家的初始状态必须一致。不能有的玩家都出完装了,有的才降生。
所以大家得一块出生,在
在这个场景中,仍然用五个线程代表大乔、兰陵王、安其拉、哪吒和铠等五个玩家。需要注意的是,各玩家虽然都调用了start()
线程,但是它们在运行时都在等待countDownLatch
的信号,在信号未收到前,它们不会往下执行。
public static void main(String[] args) throws InterruptedException { CountDownLatch countDownLatch = new CountDownLatch(1); Thread 大乔 = new Thread(() -> waitToFight(countDownLatch)); Thread 兰陵王 = new Thread(() -> waitToFight(countDownLatch)); Thread 安其拉 = new Thread(() -> waitToFight(countDownLatch)); Thread 哪吒 = new Thread(() -> waitToFight(countDownLatch)); Thread 铠 = new Thread(() -> waitToFight(countDownLatch)); 大乔.start(); 兰陵王.start(); 安其拉.start(); 哪吒.start(); 铠.start(); Thread.sleep(1000); countDownLatch.countDown(); System.out.println("敌方还有5秒达到战场,全军出击!"); } private static void waitToFight(CountDownLatch countDownLatch) { try { countDownLatch.await(); // 在此等待信号再继续 System.out.println("收到,发起进攻!"); } catch (InterruptedException e) { e.printStackTrace(); } }
CountDownLatch的核心方法也不多:
await()
:等待latch降为0;boolean await(long timeout, TimeUnit unit)
:等待latch降为0,但是可以设置超时时间。比如有玩家超时未确认,那就重新匹配,总不能为了某个玩家等到天荒地老。countDown()
:latch数量减1;getCount()
:获取当前的latch数量。CyclicBarrier的字面意思是可循环使用(Cyclic)的屏障(Barrier)。它要做的事情是,让一 组线程到达一个屏障(也可以叫同步点)时被阻塞,直到最后一个线程到达屏障时,屏障才会开门,所有被屏障拦截的线程才会继续运行。
它和CountDownLatch类似,都可以协调多线程的结束动作,在它们结束后都可以执行特定动作,但是为什么要有CyclicBarrier,自然是它有和CountDownLatch不同的地方。
不知道你听没听过一个新人UP主小约翰可汗,小约翰生平有两大恨——“Java 동시성 관련 지식 요약”我们来还原一下事情的经过:小约翰在亲政后认识了新垣结衣,于是决定第一次选妃,向结衣表白,等待回应。然而新垣结衣回应嫁给了星野源,小约翰伤心欲绝,发誓生平不娶,突然发现了铃木爱理,于是小约翰决定第二次选妃,求爱理搭理,等待回应。
我们拿代码模拟这一场景,发现CountDownLatch无能为力了,因为CountDownLatch的使用是一次性的,无法重复利用,而这里等待了两次。此时,我们用CyclicBarrier就可以实现,因为它可以重复利用。
Java 동시성 관련 지식 요약:
CyclicBarrier最最核心的方法,仍然是await():
上面的例子抽象一下,本质上它的流程就是这样就是这样:
两者最核心的区别[18]:
두 항목의 차이점은 표에 정리되어 있습니다.
CyclicBarrier | CountDownLatch |
---|---|
CyclicBarrier는 재사용이 가능하며 그 안의 스레드는 모든 스레드가 작업을 완료할 때까지 기다립니다. 이때 장벽이 제거되고 일부 특정 작업을 선택적으로 수행할 수 있습니다. | CountDownLatch는 일회성이며 카운터가 0이 될 때까지 다른 스레드가 동일한 카운터에서 작동합니다. |
CyclicBarrier는 스레드 수를 지향합니다. | CountDownLatch는 작업 수를 지향합니다 |
CyclicBarrier를 사용할 때 , 생성자에서 공동 작업에 참여하는 스레드 수를 지정해야 합니다. 이러한 스레드는 wait() 메서드를 호출해야 합니다 | CountDownLatch를 사용할 때는 이러한 작업을 완료하는 스레드가 무엇인지는 중요하지 않습니다. |
CountDownLatch는 카운터가 0일 때 더 이상 사용할 수 없습니다 | |
CountDownLatch에서는 한 스레드에 문제가 발생해도 다른 스레드에는 영향을 미치지 않습니다 |
위 내용은 Java 동시성 관련 지식 요약의 상세 내용입니다. 자세한 내용은 PHP 중국어 웹사이트의 기타 관련 기사를 참조하세요!