秋招笔记5——操作系统

文章目录

java虚拟机

jvm架构

jvm

img

如何装载类?

加载、链接、初始化。然后是使用和卸载了。

加载

JVM在第一次读取到一种class类型时,通过全限定名来加载生成 类名.class 文件到内存中

1.启动类加载器 – 负责从启动类路径中加载类,如rt.jar。这个加载器会被赋予最高优先级。

加载的是JVM自身需要的类,这个类加载使用C++语言实现的,是虚拟机自身的一部分,它负责/lib路径下的核心类库或-Xbootclasspath参数指定的路径下的jar包加载到内存中,注意必由于虚拟机是按照文件名识别加载jar包的,如rt.jar,如果文件名不被虚拟机识别,即使把jar包丢到lib目录下也是没有作用的(出于安全考虑,Bootstrap启动类加载器只加载包名为java、javax、sun等开头的类)。

2.扩展类加载器 – 负责加载ext 目录(jre\lib)内的类.

扩展类加载器是指Sun公司(已被Oracle收购)实现的sun.misc.Launcher$ExtClassLoader类,由Java语言实现的,是Launcher的静态内部类,它负责加载/lib/ext目录下或者由系统变量-Djava.ext.dir指定位路径中的类库,开发者可以直接使用标准扩展类加载器

3.应用程序类加载器 – 负责加载应用程序级别类路径,涉及到路径的环境变量ClassPath

也称应用程序加载器是指 Sun公司实现的sun.misc.Launcher$AppClassLoader。它负责加载系统类路径java -classpath或-D java.class.path 指定路径下的类库,也就是我们经常用到的classpath路径,开发者可以直接使用系统类加载器,一般情况下该类加载是程序中默认的类加载器,通过ClassLoader#getSystemClassLoader()方法可以获取到该类加载器。

4.自定义加载器

链接

链接阶段目的:将加载到JVM中的二进制字节流的类数据信息合并到JVM的运行时状态中。

1.验证这个 class 文件。包括文件格式校验、元数据验证,字节码校验,符号引用验证。

2.准备。是对这个对象分配内存。静态变量初始化为默认值。(如static int i=5;这里只将i初始化为0,至于5的值将在初始化时赋值),这里不包含用final修饰的static,因为final在编译的时候就会分配了,注意这里不会为实例变量分配初始化,类变量会分配在方法区中,而实例变量是会随着对象一起分配到Java堆中。

3.解析。是将符号引用转化为直接引用(指针引用)

初始化

1.先为静态变量赋初始值

2.再执行static代码块。(如前面只初始化了默认值的static变量将会在这个阶段赋值)。
注意:static代码块只有JVM能够调用

最终,有了大写Class实例,Class中保存了该class的所有信息,包括类名、包名、父类、实现的接口、所有方法、字段等

ClassLoader只是将类加载到了JVM 当中,而 Class.forName() 在加载类的同时还默认对类进了初始化,在对类进行初始化的过程中会执行类中的静态代码块,以及对静态变量的赋值等操作。

public static Class<?> forName(String className)
            throws ClassNotFoundException {
    Class<?> caller = Reflection.getCallerClass();
    return forName(className, true, ClassLoader.getClassLoader(caller), caller);//注意第二个参数,指定是否进行初始化
}

如何分配内存?

JMM(Java内存模型)

JVM规范

img

JVM 分为堆区和栈区,还有方法区,初始化的对象放在堆里面,引用放在栈里面,class 类信息常量池(static 常量和 static 变量)等放在方法区

方法区:主要是存储类信息,常量池(static 常量和 static 变量),编译后的代码(字节码)等数据

堆:初始化的对象,成员变量 (那种非 static 的变量),所有的对象实例和数组都要在堆上分配

栈:栈的结构是栈帧组成的,调用一个方法就压入一帧,帧上面存储局部变量表,操作数栈,方法出口等信息,局部变量表存放的是 8 大基础类型加上一个引用类型,所以还是一个指向地址的指针

本地方法栈:主要为 Native 方法服务

程序计数器:记录当前线程执行的行号

JVM内存结构(内存布局)

image.png

Java 8 中 PermGen 被移出 HotSpot JVM,方法区移至 Metaspace,字符串常量池移至堆区

jdk7时,Perm 区中的字符串常量池已经被移到了堆内存,Java 8 时,PermGen 被元空间代替,其他内容比如类元信息、字段、静态属性、方法、常量等都移动到元空间区,元空间并不在虚拟机中,而是使用本地内存。比如java.lang.Object类元信息、静态属性System.out、整形常量 100000等。

(延伸)堆里面的分区:Eden,survival (from+ to),老年代,各自的特点。

堆里面分为新生代和老生代(java8 取消了永久代,采用了 Metaspace),新生代包含 Eden+Survivor 区,survivor 区里面分为 from 和 to 区,内存回收时,如果用的是复制算法,从 from 复制到 to,当经过一次或者多次 GC 之后,存活下来的对象会被移动到老年区,当 JVM 内存不够用的时候,会触发 Full GC,清理 JVM 老年区当新生区满了之后会触发 YGC,先把存活的对象放到其中一个 Survice区,然后进行垃圾清理。因为如果仅仅清理需要删除的对象,这样会导致内存碎片,因此一般会把 Eden 进行完全的清理,然后整理内存。那么下次 GC 的时候,就会使用下一个 Survive,这样循环使用。如果有特别大的对象,新生代放不下,就会使用老年代的担保,直接放到老年代里面。因为 JVM 认为,一般大对象的存活时间一般比较久远。

(延伸)那么,Java 8 中 PermGen 为什么被移出 HotSpot JVM 了?(详见:JEP 122: Remove the Permanent Generation):

  1. 由于 PermGen 内存经常会溢出,引发恼人的 java.lang.OutOfMemoryError: PermGen,因此 JVM 的开发者希望这一块内存可以更灵活地被管理,不要再经常出现这样的 OOM
  2. 移除 PermGen 可以促进 HotSpot JVM 与 JRockit VM 的融合,因为 JRockit 没有永久代。

java对象内存查看工具

JOL = Java Object Layout

<dependencies> 
<!-- https://mvnrepository.com/artifact/org.openjdk.jol/jol-core -->
<dependency> 
<groupId>org.openjdk.jol</groupId>
<artifactId>jol-core</artifactId>
<version>0.9</version> 
</dependency> 
</dependencies>

Java对象头

markword占4/8字节(32位JVM/64位JVM) 与指针压缩无关

Class类指针4/8字节(是否开启指针压缩)

(可选)长度字段4字节,数组才有

img

jvm的源码中定义的markword如下

jdk8u: markOop.hpp

// 对象头的bit格式 (大端布局):
 //
 //  32 bits:
 //  --------
 //             hash:25 ------------>| age:4    biased_lock:1 lock:2 (normal object)
 //             JavaThread*:23 epoch:2 age:4    biased_lock:1 lock:2 (biased object)
 //             size:32 ------------------------------------------>| (CMS free block)
 //             PromotedObject*:29 ---------->| promo_bits:3 ----->| (CMS promoted object)

 //  64 bits:
 //  --------
 //  unused:25 hash:31 -->| unused:1   age:4    biased_lock:1 lock:2 (normal object)
 //  JavaThread*:54 epoch:2 unused:1   age:4    biased_lock:1 lock:2 (biased object)
 //  PromotedObject*:61 --------------------->| promo_bits:3 ----->| (CMS promoted object)
 //  size:64 ----------------------------------------------------->| (CMS free block)

 //  unused:25 hash:31 -->| cms_free:1 age:4    biased_lock:1 lock:2 (COOPs && normal object)
 //  JavaThread*:54 epoch:2 cms_free:1 age:4    biased_lock:1 lock:2 (COOPs && biased object)
 //  narrowOop:32 unused:24 cms_free:1 unused:4 promo_bits:3 ----->| (COOPs && CMS promoted object)
 //  unused:21 size:35 -->| cms_free:1 unused:7 ------------------>| (COOPs && CMS free block)

锁存在Java对象头里。如果对象是数组类型,则虚拟机用3个Word(字宽)存储对象头,如果对象是非数组类型,则用2字宽存储对象头。在32位虚拟机中,一字宽等于四字节,即32bit。

长度 内容 说明
32/64bit Mark Word 存储对象的hashCode或锁信息,垃圾回收信息等
32/64bit Class Metadata Address 存储到对象类型数据Class的指针
32/64bit Array length 数组的长度(如果当前对象是数组才有此字段)

Java对象头里的Mark Word里默认存储对象的HashCode,分代年龄和锁标记位。

32位JVM的Mark Word的默认存储结构如下:

25 bit 4bit 1bit 是否是偏向锁 2bit 锁标志位
无锁状态 对象的hashCode 对象分代年龄 0 01

在运行期间Mark Word里存储的数据会随着锁标志位的变化而变化。Mark Word可能变化为存储以下4种数据:

img

64位JVM的Mark Word的默认存储结构如下:

image.png

age,分代年龄一直为4位的原因是我们 gc 的年龄最大是15就会被回收到老年代,所以 4 个 bit位就可以表示

lock 2位只能表示4种状态,加上偏向标志biased lock 1位则可以表示5种状态

epoch 是用来标示是否已经重偏向

关于epoch: (不重要)

批量重偏向与批量撤销渊源:从偏向锁的加锁解锁过程中可看出,当只有一个线程反复进入同步块时,偏向锁带来的性能开销基本可以忽略,但是当有其他线程尝试获得锁时,就需要等到safe point时,再将偏向锁撤销为无锁状态或升级为轻量级,会消耗一定的性能,所以在多线程竞争频繁的情况下,偏向锁不仅不能提高性能,还会导致性能下降。于是,就有了批量重偏向与批量撤销的机制。

原理以class为单位,为每个class维护解决场景批量重偏向(bulk rebias)机制是为了解决:一个线程创建了大量对象并执行了初始的同步操作,后来另一个线程也来将这些对象作为锁对象进行操作,这样会导致大量的偏向锁撤销操作。批量撤销(bulk revoke)机制是为了解决:在明显多线程竞争剧烈的场景下使用偏向锁是不合适的。

一个偏向锁撤销计数器,每一次该class的对象发生偏向撤销操作时,该计数器+1,当这个值达到重偏向阈值(默认20)时,JVM就认为该class的偏向锁有问题,因此会进行批量重偏向。每个class对象会有一个对应的epoch字段,每个处于偏向锁状态对象的Mark Word中也有该字段,其初始值为创建该对象时class中的epoch的值。每次发生批量重偏向时,就将该值+1,同时遍历JVM中所有线程的栈,找到该class所有正处于加锁状态的偏向锁,将其epoch字段改为新值。下次获得锁时,发现当前对象的epoch值和class的epoch不相等,那就算当前已经偏向了其他线程,也不会执行撤销操作,而是直接通过CAS操作将其Mark Word的Thread Id 改成当前线程Id。当达到重偏向阈值后,假设该class计数器继续增长,当其达到批量撤销的阈值后(默认40),JVM就认为该class的使用场景存在多线程竞争,会标记该class为不可偏向,之后,对于该class的锁,直接走轻量级锁的逻辑。

32位jvm一个空的Object对象是8字节(4+4)

64位jvm会默认开启内存压缩导致也是8字节?错,16字节(8+4压缩后+4填充)(仅在32G以内生效,CPU的4位指针寻址是4G个字节,但是jvm规定对象8字节为单位对齐,最小粒度是8字节,因此4*8=32G)

不开启则仍是16字节8+8

(拓展,内存压缩的好处)指针占的内存小呀,java全是对象引用,也就是很多指针。可用空间多了,减少GC开销,增加CPU缓存命中率。

https://blog.csdn.net/zxd1435513775/article/details/101760124)

java new对象数组,内存分布

transient Node<K,V>[] table;
static final class TreeNode<K,V> extends LinkedHashMap.Entry<K,V>
static class Entry<K,V> extends HashMap.Node<K,V> 
static class Node<K,V> implements Map.Entry<K,V>

注意LinkedHashMap.Entry与 Map.Entry不同,实际上是Node的子类,因此table可以容纳treeNode类型版本1

如何函数调用?

栈桢

Cal 1226 <__x86.get_pc_thunk.ax>获取到下一条指令的地址?并赋值给eax寄存器。这有什么作用吗?总之是由于pic位置无关代码的原因,用eax作为pc(eip寄存器)使用。这里没用上。

在64位计算机上,编译成32位程序,32位程序不支持直接访问pc,这时候程序默认使用了pic选项

为什么被调用程序也需要保存寄存器最后再恢复?IA-32中的规定8个通用寄存器中eax,ecx,edx调用者保存,ebx,esi,edi被调用者保存,ebp被调用者保存,esp则一直动态变化。

image-20220517114345969

Leave指令回收被调用函数的栈空间,调用函数还没有变,直接从ebp到esp全部干掉,然后ebp变为调用函数的eb,esp也为调用函数的esp,此时esp刚好指向返回地址。

Ret指令,将返回地址被弹出,送入eip寄存器,esp也上移

然后执行调用Add,回收部分栈空间,恢复到未调用函数前的状态。

实例-Java对象如何创建

假设已经装载类。

Animal a = new Animal("Molly","2")

.class文件中的字节码指令,我们可以使用JDK提供的工具javap -c xx.class进行反汇编得到jvm的指令

0: new      #2         // class com/galaxy/helloworld/entity/Animal      
3: dup              //含义是复制,duplicate
4: ldc      #3         // String Molly      //含义是加载常量,load constants 
6: ldc      #4         // String 2      
8: invokespecial #5         // Method com/galaxy/helloworld/entity/Animal."<init>":(Ljava/lang/String;Ljava/lang/String;)V     
11: astore_1     //存储指令,store
12: return

new包含 装载类,分配内存,初始化(对象头)。

分配内存:内存规整—指针碰撞(标记整理)。内存不规整—空闲列表。

(延伸:分配内存时并发问题)CAS或者每个线程预先分配一块TLAB区域(在堆当中Eden区中的,大概占整个Eden区的1%左右),可以通过-XX:+ /-UseTLAB参数来设定

dup invokeSpecial会弹出栈顶的操作数[引用],但是对象访问后还是需要引用的,所以这个时候复制了引用就有作用了

ldc加载常量

invokespecial 用于调用一些需要特殊处理的实例方法,包括 实例初始化方法(构造器)、私有方法和父类方法

1.在堆区分配对象需要的内存。分配的内存包括本类和父类的所有实例变量,但不包括任何静态变量,所有对象共享一个静态变量。
2.对所有实例变量赋默认值。

3.设置对象的对象头。将对象的所属类(即类的元数据信息)、对象的HashCode、对象的GC信息、锁信息等数据存储在对象头中。

4..初始化。init方法。父类非静态代码块。父类构造方法。子类非静态代码块。子类构造函数
5.如果有类似于Child c = new Child()形式的c引用的话,在栈区定义Child类型引用变量c,然后将堆区对象的地址赋值给它

JVM的锁(处理并发)

https://cloud.fynote.com/share/d/9770

CAS底层如何实现

juc中的AtomicInteger调用了unsafe类的方法

public final int incrementAndGet() {
         for (;;) {
             int current = get();
             int next = current + 1;
             if (compareAndSet(current, next))
                 return next;
         }
     }

 public final boolean compareAndSet(int expect, int update) {
         return unsafe.compareAndSwapInt(this, valueOffset, expect, update);
     }

Unsafe类中的方法是native,通过jni调用c/c++的dll

Unsafe类的功能

image-20221120121828975

public final native boolean compareAndSwapInt(Object var1, long var2, int var4, int var5);

运用:

package com.mashibing.jol;

 import sun.misc.Unsafe;

 import java.lang.reflect.Field;

 public class T02_TestUnsafe {

     int i = 0;
     private static T02_TestUnsafe t = new T02_TestUnsafe();

     public static void main(String[] args) throws Exception {
         //Unsafe unsafe = Unsafe.getUnsafe();注意没有办法直接获取unsafe类对象。getUnsafe方法中,会对调用者的classLoader进行检查,判断当前类是否由Bootstrap classLoader加载,如果不是的话那么就会抛出一个SecurityException异常。也就是说,只有启动类加载器加载的类才能够调用Unsafe类中的方法,来防止这些方法在不可信的代码中被调用。
         Field unsafeField = Unsafe.class.getDeclaredField("theUnsafe");//获取字段
         Field unsafeField = Unsafe.class.getDeclaredFields()[0];//也可以这样获取
         unsafeField.setAccessible(true);
         Unsafe unsafe = (Unsafe) unsafeField.get(null);

         Field f = T02_TestUnsafe.class.getDeclaredField("i");
         long offset = unsafe.objectFieldOffset(f);
         System.out.println(offset);

         boolean success = unsafe.compareAndSwapInt(t, offset, 0, 1);
         System.out.println(success);
         System.out.println(t.i);
         //unsafe.compareAndSwapInt()
     }
 }

cas对应的c++源码,jdk8u: unsafe.cpp:

cmpxchg = compare and exchange

UNSAFE_ENTRY(jboolean, Unsafe_CompareAndSwapInt(JNIEnv *env, jobject unsafe, jobject obj, jlong offset, jint e, jint x))
   UnsafeWrapper("Unsafe_CompareAndSwapInt");
   oop p = JNIHandles::resolve(obj);
   jint* addr = (jint *) index_oop_from_field_offset_long(p, offset);
   return (jint)(Atomic::cmpxchg(x, addr, e)) == e;
 UNSAFE_END

jdk8u: atomic_linux_x86.inline.hpp

image-20220530205001540

注意上图中的cmpxchgl汇编命令实现cas 。lock保证多cpu下该命令原子性, lock的硬件实现可以有,关中断,锁缓存,锁总线(拉高北桥芯片电平信号)

jdk8u: os.hpp is_MP()

static inline bool is_MP() {
     // 在引导过程中,如果_processor_count还没有初始化,我们就声称MP是最安全的。如果任何平台有一个桩代码生成器,它可能在这个阶段(processor_count还没有初始化)被触发,并且被声明为MP,而实际上不是,这是一个问题。那么桩代码生成器的引导例程需要直接检查处理器计数,并在初始化发生后调用引导例程。
     return (_processor_count != 1) || AssumeMP;
   }

image-20220530205328791

synchronized源码

https://cloud.tencent.com/developer/article/1580329

java源码层级

synchronized(o)

字节码层级

Synchronized 关键字在编译后会在同步块的前后生成 monitorentermonitorexit 这两个字节码指令,JVM 会对其进行解析,如果锁的是对象则就是对该对象加锁解锁,如果是类方法则是对 Class 对象加锁解锁,如果是实例方法则是对对应的对象加锁解锁。

JVM层级(Hotspot)

package com.mashibing.insidesync;

 import org.openjdk.jol.info.ClassLayout;

 public class T01_Sync1 {

     public static void main(String[] args) {
         Object o = new Object();

         System.out.println(ClassLayout.parseInstance(o).toPrintable());
     }
 }
//101表示是偏向锁,但还没有线程使用
com.mashibing.insidesync.T01_Sync1$Lock object internals:
  OFFSET  SIZE   TYPE DESCRIPTION                               VALUE
       0     4   (object header)  05 00 00 00 (00000101 00000000 00000000 00000000) (5)
       4     4   (object header)  00 00 00 00 (00000000 00000000 00000000 00000000) (0)
       8     4   (object header)  49 ce 00 20 (01001001 11001110 00000000 00100000) (536923721)
      12     4        (loss due to the next object alignment)
 Instance size: 16 bytes
 Space losses: 0 bytes internal + 4 bytes external = 4 bytes total
//101表示是偏向锁,当前线程使用,当前线程的指针JavaThread*     
com.mashibing.insidesync.T02_Sync2$Lock object internals:
  OFFSET  SIZE   TYPE DESCRIPTION                               VALUE
       0     4   (object header)  05 90 2e 1e (00000101 10010000 00101110 00011110) (506368005)
       4     4   (object header)  1b 02 00 00 (00011011 00000010 00000000 00000000) (539)
       8     4   (object header)  49 ce 00 20 (01001001 11001110 00000000 00100000) (536923721)
      12     4        (loss due to the next object alignment)
 Instance size: 16 bytes
 Space losses: 0 bytes internal + 4 bytes external = 4 bytes tota

HotSpot 虚拟机实现了两种的解释器,分别是模板解释器 和 C++解释器,默认使用的是模板解释器。

C++ 解释器也即是我们平时用来实现功能的方法,简单明了但是很慢;模板解释器是跳过了编译器,自己使用汇编代码来做的,所以 monitorenter 的两个入口

我们先从 C++ 解释器的入口分析,更加容易明白。

InterpreterRuntime:: monitorenter方法

IRT_ENTRY_NO_ASYNC(void, InterpreterRuntime::monitorenter(JavaThread* thread, BasicObjectLock* elem))
 #ifdef ASSERT
   thread->last_frame().interpreter_frame_verify_monitor(elem);
 #endif
   if (PrintBiasedLockingStatistics) {
     Atomic::inc(BiasedLocking::slow_path_entry_count_addr());
   }
   Handle h_obj(thread, elem->obj());
   assert(Universe::heap()->is_in_reserved_or_null(h_obj()),
          "must be NULL or an object");
   if (UseBiasedLocking) {
     // 如果有偏向锁,快速进入,避免不必要的锁膨胀
     ObjectSynchronizer::fast_enter(h_obj, elem->lock(), true, CHECK);
   } else {
     ObjectSynchronizer::slow_enter(h_obj, elem->lock(), CHECK);
   }
   assert(Universe::heap()->is_in_reserved_or_null(elem->obj()),
          "must be NULL or an object");
 #ifdef ASSERT
   thread->last_frame().interpreter_frame_verify_monitor(elem);
 #endif
 IRT_END

synchronizer.cpp

revoke_and_rebias

void ObjectSynchronizer::fast_enter(Handle obj, BasicLock* lock, bool attempt_rebias, TRAPS) {
  if (UseBiasedLocking) {
     if (!SafepointSynchronize::is_at_safepoint()) {
       BiasedLocking::Condition cond = BiasedLocking::revoke_and_rebias(obj, attempt_rebias, THREAD);
       if (cond == BiasedLocking::BIAS_REVOKED_AND_REBIASED) {
         return;
       }
     } else {
       assert(!attempt_rebias, "can not rebias toward VM thread");
       BiasedLocking::revoke_at_safepoint(obj);
     }
     assert(!obj->mark()->has_bias_pattern(), "biases should be revoked by now");
  }

  slow_enter (obj, lock, THREAD) ;
 }
void ObjectSynchronizer::slow_enter(Handle obj, BasicLock* lock, TRAPS) {
   markOop mark = obj->mark();
   assert(!mark->has_bias_pattern(), "should not see bias pattern here");

   if (mark->is_neutral()) {
     // Anticipate successful CAS -- the ST of the displaced mark must
     // be visible <= the ST performed by the CAS.
     lock->set_displaced_header(mark);
     if (mark == (markOop) Atomic::cmpxchg_ptr(lock, obj()->mark_addr(), mark)) {
       TEVENT (slow_enter: release stacklock) ;
       return ;
     }
     // Fall through to inflate() ...
   } else
   if (mark->has_locker() && THREAD->is_lock_owned((address)mark->locker())) {
     assert(lock != mark->locker(), "must not re-lock the same lock");
     assert(lock != (BasicLock*)obj->mark(), "don't relock with same BasicLock");
     lock->set_displaced_header(NULL);
     return;
   }

 #if 0
   // The following optimization isn't particularly useful.
   if (mark->has_monitor() && mark->monitor()->is_entered(THREAD)) {
     lock->set_displaced_header (NULL) ;
     return ;
   }
 #endif

   // The object header will never be displaced to this lock,
   // so it does not matter what the value is, except that it
   // must be non-zero to avoid looking like a re-entrant lock,
   // and must not look locked either.
   lock->set_displaced_header(markOopDesc::unused_mark());
   ObjectSynchronizer::inflate(THREAD, obj())->enter(THREAD);//膨胀为重量级锁
 }

模板解释器

public class Main {
     static volatile int i = 0;

     public static void n() { i++; }

     public static synchronized void m() {}

     publics static void main(String[] args) {
         for(int j=0; j<1000_000; j++) {
             m();
             n();
         }
     }
 }

java -XX:+UnlockDiagnosticVMOptions -XX:+PrintAssembly com.company.Main > 1.txt可以看反汇编的模板解释器(jvm直接调用汇编代码,类似JIT)

C1 Compile Level 1 (一级优化)

C2 Compile Level 2 (二级优化)

找到m() n()方法的汇编码,会看到 lock comxchg …..指令

锁升级

image.png

轻量级锁(无锁,自旋锁,自适应自旋)是jvm管理,重量级锁,交给操作系统。

轻量级锁,所有线程活着,重量级锁,操作系统将线程放入等待状态,不消耗cpu

o.hashCode()
001 + hashcode

jdk8偏向锁是默认开启,但是是有延时的,jvm 4s以后启用偏向锁(因为JVM启动过程,会有很多线程竞争(里面有好多sync代码,这些sync代码启动时就知道肯定会有竞争,如果使用偏向锁,就会造成偏向锁不断的进行锁撤销和锁升级的操作,效率较低),所以默认情况启动时不打开偏向锁,过一段儿时间再打开。可通过参数: -XX:BiasedLockingStartupDelay=0关闭延时。)

如果设定上述参数
new Object () – > 101 偏向锁 ->线程ID为0 -> Anonymous BiasedLock
打开偏向锁,new出来的对象,默认就是一个可偏向匿名对象101

如果有线程上锁
上偏向锁,指的就是,把markword的线程ID改为自己线程ID的过程
偏向锁不可重偏向 批量偏向 批量撤销

如果有线程竞争
撤销偏向锁,升级轻量级锁
线程在自己的线程栈生成LockRecord ,用CAS操作将markword设置为指向自己这个线程的LR的指针,设置成功者得到锁

hashcode是懒加载,在调用hashCode方法后才会保存在对象头中。

当对象头中没有hashcode时,对象头锁的状态是 可偏向( biasable,101,且无线程id)。

如果在同步代码块之前调用hashCode方法,则对象头中会有hashcode,且锁状态是 不可偏向(0 01)则对象无法进入偏向状态!这时候再执行同步代码块,锁直接是 轻量级锁(thin lock,00)。

Java synchronized偏向锁后hashcode存在哪里?_java_03

如果是在同步代码块中执行hashcode,则锁是从 偏向锁 直接膨胀为 重量级锁。

Java synchronized偏向锁后hashcode存在哪里?_面试_04

轻量级锁重量级锁的hashCode存在与什么地方?

答案:线程栈中,轻量级锁的LR中,或是代表重量级锁的ObjectMonitor的成员中

锁重入

sychronized是可重入锁

重入次数必须记录,因为要解锁几次必须得对应

偏向锁 自旋锁 -> 线程栈 -> LR + 1

重量级锁 -> ? ObjectMonitor字段上

锁消除 lock eliminate

public void add(String str1,String str2){
          StringBuffer sb = new StringBuffer();
          sb.append(str1).append(str2);
 }

我们都知道 StringBuffer 是线程安全的,因为它的关键方法都是被 synchronized 修饰过的,但我们看上面这段代码,我们会发现,sb 这个引用只会在 add 方法中使用,不可能被其它线程引用(因为是局部变量,栈私有),因此 sb 是不可能共享的资源,JVM 会自动消除 StringBuffer 对象内部的锁。

锁粗化 lock coarsening

public String test(String str){

        int i = 0;
        StringBuffer sb = new StringBuffer():
        while(i < 100){
            sb.append(str);
            i++;
        }
        return sb.toString():
 }

JVM 会检测到这样一连串的操作都对同一个对象加锁(while 循环内 100 次执行 append,没有锁粗化的就要进行 100 次加锁/解锁),此时 JVM 就会将加锁的范围粗化到这一连串的操作的外部(比如 while 虚幻体外),使得这一连串操作只需要加一次锁即可。

锁降级(不重要)

https://www.zhihu.com/question/63859501

其实,只被VMThread访问,降级也就没啥意义了。所以可以简单认为锁降级不存在!但是是可以降级的

超线程

一个ALU + 两组Registers + PC

intel的超线程技术是给处理器添加一个逻辑处理单元ALU,其余部分的运算单元基本是没有变化的,基本原理是对于不同的不相关线程,令部分闲置的运算单元也能充分利用。

CPU在执行一条机器指令时,并不会完全地利用所有的CPU资源,而且实际上,是有大量资源被闲置着的。超线程技术允许两个线程同时不冲突地使用CPU中的资源。比如一条整数运算指令只会用到整数运算单元,此时浮点运算单元就空闲了,若使用了超线程技术,且另一个线程刚好此时要执行一个浮点运算指令,CPU就允许属于两个不同线程的整数运算指令和浮点运算指令同时执行,这是真的并行。

我不了解其它的硬件多线程技术是怎么样的,但单就超线程技术而言,它是可以实现真正的并行的。但这也并不意味着两个线程在同一个CPU中一直都可以并行执行,只是恰好碰到两个线程当前要执行的指令不使用相同的CPU资源时才可以真正地并行执行。

膨胀

竞争加剧:有线程超过10次自旋,自旋锁在 JDK1.4.2 中引入,使用 -XX:+UseSpinning 来开启,JDK 6 中变为默认开启, 或者自旋线程数超过CPU核数的一半, 1.6之后,加入自适应自旋 Adapative Self Spinning , JVM自己控制。自适应自旋锁意味着自旋的时间(次数)不再固定,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。如果在同一个锁对象上,自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行中,那么虚拟机就会认为这次自旋也是很有可能再次成功,进而它将允许自旋等待持续相对更长的时间。如果对于某个锁,自旋很少成功获得过,那在以后尝试获取这个锁时将可能省略掉自旋过程,直接阻塞线程,避免浪费处理器资源。

偏向锁由于有锁撤销的过程revoke,会消耗系统资源,所以,在锁争用特别激烈的时候,用偏向锁未必效率高。还不如直接使用轻量级锁。

此时会膨胀为重量级锁。

JDK早期,synchronized 叫做重量级锁, 因为申请锁资源必须通过kernel,系统调用。向操作系统申请资源,linux mutex , CPU从3级-0级系统调用,线程挂起,进入等待队列,等待操作系统的调度,然后再映射回用户空间

锁竞争是什么?偏向锁,第一个线程,避免锁竞争

一个线程的偏向锁,为什么比一个线程的锁竞争(锁竞争的具体代码实现过程是啥)快?image-20220530214248259

何为同步?JVM规范规定JVM基于进入和退出Monitor对象来实现方法同步和代码块同步,但两者的实现细节不一样。代码块同步是使用monitorenter和monitorexit指令实现,而方法同步是使用另外一种方式实现的,细节在JVM规范里并没有详细说明,但是方法的同步同样可以使用这两个指令来实现。monitorenter指令是在编译后插入到同步代码块的开始位置,而monitorexit是插入到方法结束处和异常处, JVM要保证每个monitorenter必须有对应的monitorexit与之配对。任何对象都有一个 monitor 与之关联,当且一个monitor 被持有后,它将处于锁定状态。线程执行到 monitorenter 指令时,将会尝试获取对象所对应的 monitor 的所有权,即尝试获得对象的锁。

img

img

加锁究竟是什么?指的是锁定对象

作用是?如果要执行被锁定的代码,需要先拿到对象的锁

几种锁的类型

线程的阻塞和唤醒需要CPU从用户态转为核心态,频繁的阻塞和唤醒对CPU来说是一件负担很重的工作。

Java SE1.6为了减少获得锁和释放锁所带来的性能消耗,引入了“偏向锁”和“轻量级锁”,所以在Java SE1.6里锁一共有四种状态,无锁状态,偏向锁状态,轻量级锁状态和重量级锁状态,它会随着竞争情况逐渐升级。锁可以升级但不能降级,意味着偏向锁升级成轻量级锁后不能降级成偏向锁。这种锁升级却不能降级的策略,目的是为了提高获得锁和释放锁的效率。

偏向锁

大多数情况下锁不仅不存在多线程竞争,而且总是由同一线程多次获得。偏向锁的目的是在某个线程获得锁之后,消除这个线程锁重入(CAS)的开销,看起来让这个线程得到了偏护。另外,JVM对那种会有多线程加锁,但不存在锁竞争的情况也做了优化,听起来比较拗口,但在现实应用中确实是可能出现这种情况,因为线程之前除了互斥之外也可能发生同步关系,被同步的两个线程(一前一后)对共享对象锁的竞争很可能是没有冲突的。对这种情况,JVM用一个epoch表示一个偏向锁的时间戳(真实地生成一个时间戳代价还是蛮大的,因此这里应当理解为一种类似时间戳的identifier)

偏向锁的获取

当一个线程访问同步块并获取锁时,会在对象头和栈帧中的锁记录里存储锁偏向的线程ID,以后该线程在进入和退出同步块时不需要花费CAS操作来加锁和解锁,而只需简单的测试一下对象头的Mark Word里是否存储着指向当前线程的偏向锁,如果测试成功,表示线程已经获得了锁,如果测试失败,则需要再测试下Mark Word中偏向锁的标识是否设置成1(表示当前是偏向锁),如果没有设置,则使用CAS竞争锁,如果设置了,则尝试使用CAS将对象头的偏向锁指向当前线程。

偏向锁的撤销

偏向锁使用了一种等到竞争出现才释放锁的机制,所以当其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁。偏向锁的撤销,需要等待全局安全点(在这个时间点上没有字节码正在执行),它会首先暂停拥有偏向锁的线程,然后检查持有偏向锁的线程是否活着,如果线程不处于活动状态,则将对象头设置成无锁状态,如果线程仍然活着,拥有偏向锁的栈会被执行,遍历偏向对象的锁记录,栈中的锁记录和对象头的Mark Word,要么重新偏向于其他线程,要么恢复到无锁或者标记对象不适合作为偏向锁,最后唤醒暂停的线程。

偏向锁的设置

关闭偏向锁:偏向锁在Java 6和Java 7里是默认启用的,但是它在应用程序启动几秒钟之后才激活,如有必要可以使用JVM参数来关闭延迟-XX:BiasedLockingStartupDelay = 0。如果你确定自己应用程序里所有的锁通常情况下处于竞争状态,可以通过JVM参数关闭偏向锁-XX:-UseBiasedLocking=false,那么默认会进入轻量级锁状态。

自旋锁

线程的阻塞和唤醒需要CPU从用户态转为核心态,频繁的阻塞和唤醒对CPU来说是一件负担很重的工作。同时我们可以发现,很多对象锁的锁定状态只会持续很短的一段时间,例如整数的自加操作,在很短的时间内阻塞并唤醒线程显然不值得,为此引入了自旋锁。

所谓“自旋”,就是让线程去执行一个无意义的循环,循环结束后再去重新竞争锁,如果竞争不到继续循环,循环过程中线程会一直处于running状态,但是基于JVM的线程调度,会出让时间片,所以其他线程依旧有申请锁和释放锁的机会。

自旋锁省去了阻塞锁的时间空间(队列的维护等)开销,但是长时间自旋就变成了“忙式等待”,忙式等待显然还不如阻塞锁。所以自旋的次数一般控制在一个范围内,例如10,100等,在超出这个范围后,自旋锁会升级为阻塞锁。

轻量级锁

加锁

线程在执行同步块之前,JVM会先在当前线程的栈桢中创建用于存储锁记录的空间,并将对象头中的Mark Word复制到锁记录中,官方称为Displaced Mark Word。然后线程尝试使用CAS将对象头中的Mark Word替换为指向锁记录的指针。如果成功,当前线程获得锁,如果失败,则自旋获取锁,当自旋获取锁仍然失败时,表示存在其他线程竞争锁(两条或两条以上的线程竞争同一个锁),则轻量级锁会膨胀成重量级锁。

解锁

轻量级解锁时,会使用原子的CAS操作来将Displaced Mark Word替换回到对象头,如果成功,则表示同步过程已完成。如果失败,表示有其他线程尝试过获取该锁,则要在释放锁的同时唤醒被挂起的线程。

重量级锁

重量锁在JVM中又叫对象监视器(Monitor),它很像C中的Mutex,除了具备Mutex(0|1)互斥的功能,它还负责实现了Semaphore(信号量)的功能,也就是说它至少包含一个竞争锁的队列,和一个信号阻塞队列(wait队列),前者负责做互斥,后一个用于做线程同步。

优点 缺点 适用场景
偏向锁 加锁和解锁不需要额外的消耗,和执行非同步方法比仅存在纳秒级的差距 如果线程间存在锁竞争,会带来额外的锁撤销的消耗 适用于只有一个线程访问同步块场景
轻量级锁 竞争的线程不会阻塞,提高了程序的响应速度 如果始终得不到锁竞争的线程使用自旋会消耗CPU 追求响应时间,锁占用时间很短
重量级锁 线程竞争不使用自旋,不会消耗CPU 线程阻塞,响应时间缓慢 追求吞吐量,锁占用时间较长

系统底层如何实现多个缓存间数据一致性

  1. MESI协议如果能解决,就使用MESICPU缓存一致性,内存屏障
  2. 如果不能,就锁总线

系统底层如何保证有序性

  1. 内存屏障sfence mfence lfence等系统原语
  2. 锁总线

volatile的用途

dcl单例到底需不需要volatile?多线程时需要。

volatile 除了可见性(从内存中读,另外一个线程修改后,他可以立刻看到),还有有序性,可以保证有序性

volatile 修饰的,jvm必须设置内存屏障,实际是操作系统提供的,写完之前不能读(屏障两边的指令不可以重排!保障有序!),从而实现顺序不可变,规范不能换顺序,底层lock addl.

hotspot实现

bytecodeinterpreter.cpp

int field_offset = cache->f2_as_index();
           if (cache->is_volatile()) {
             if (support_IRIW_for_not_multiple_copy_atomic_cpu) {
               OrderAccess::fence();
             }

orderaccess_linux_x86.inline.hpp

inline void OrderAccess::fence() {
   if (os::is_MP()) {
     // always use locked addl since mfence is sometimes expensive
 #ifdef AMD64
     __asm__ volatile ("lock; addl $0,0(%%rsp)" : : : "cc", "memory");
 #else
     __asm__ volatile ("lock; addl $0,0(%%esp)" : : : "cc", "memory");
 #endif
   }
 }

指令重排

多线程时指令重排才会出现问题。

单线程如果保证执行结果,那么可以乱序,加快效率,但是多线程则可能出现问题,课通过volatile避免

package com.mashibing.jvm.c3_jmm;

 public class T04_Disorder {
     private static int x = 0, y = 0;
     private static int a = 0, b =0;

     public static void main(String[] args) throws InterruptedException {
         int i = 0;
         for(;;) {
             i++;
             x = 0; y = 0;
             a = 0; b = 0;
             Thread one = new Thread(new Runnable() {
                 public void run() {
                     //由于线程one先启动,下面这句话让它等一等线程two. 读着可根据自己电脑的实际性能适当调整等待时间.
                     //shortWait(100000);
                     a = 1;
                     x = b;
                 }
             });

             Thread other = new Thread(new Runnable() {
                 public void run() {
                     b = 1;
                     y = a;
                 }
             });
             one.start();other.start();
             one.join();other.join();
             String result = "第" + i + "次 (" + x + "," + y + ")";
             if(x == 0 && y == 0) {
                 System.err.println(result);
                 break;
             } else {
                 //System.out.println(result);
             }
         }
     }

     public static void shortWait(long interval){
         long start = System.nanoTime();
         long end;
         do{
             end = System.nanoTime();
         }while(start + interval >= end);
     }
 }

如果指令不会重排,理论上 A44/A22/A22=6种情况。结果只有01,10,11没有11、因为不管哪个线程先走,必定先赋值一次,导致变为1.

但是实际上出现了其他情况。

DCL

两次很有必要,避免锁竞争,提高效率

image-20220529212731721

如何垃圾回收?

如何标记垃圾?

引用计数法:指的是如果某个地方引用了这个对象就+1,如果失效了就-1,当为 0 就会回收但是 JVM 没有用这种方式,因为无法判定相互循环引用(A 引用 B,B 引用 A)的情况

引用链法: 通过一种 GC ROOT 的对象(方法区中静态变量引用的对象等-static 变量)来判断,如果有一条链能够到达 GC ROOT 就说明是存活的对象,不能到达 GC ROOT 就说明可以回收。

(延伸)实际发现垃圾的过程

1.根搜索算法判断不可用
2.看是否有必要执行finalize方法(finalize方法被尝试回收时会被调用一次。可以在这里面重新引用自己。)
3.两个步骤走完后对象仍然没有人使用,那就属于垃圾。

(延伸,判断Class类是否可以卸载)

需要满足如下四个条件

1.JVM中该类的所有实例都已经被回收
2.加载该类的ClassLoader已经被回收
3.没有任何地方引用该类的Class对象

4.无法在任何地方通过反射访问这类

可作为GC Roots的对象

主要是栈中的对象。

  • 虚拟机栈中引用的对象。比如:各个线程被调用的方法中使用到的参数、局部变量等。

  • 本地方法栈内JNI(通常说的本地方法)引用的对象

  • 方法区中类静态属性引用的对象
    比如:Java类的引用类型静态变量

  • 方法区中常量引用的对象
    比如:字符串常量池(String Table)里的引用

  • 所有被同步锁synchronized持有的对象

  • Java虚拟机内部的引用。
    基本数据类型对应的class对象,一些常驻的异常对象(如:Null PointerException、OutofMemoryError),系统类加载器。

  • 反映java虚拟机内部情况的JMXBean、JVTI中注册的回调、本地代码缓存等。

除了这些固定的GC Roots集合以外,根据用户所选用的垃圾收集器以及当前回收的内存区域不同,还可以有其他对象“临时性”地加入,共同构成完整GC Roots集合。比如:分代收集和局部回收(Partial GC)

跨代引用

如果只针对]ava堆中的某一块区域进行垃圾回收(比如:典型的只针对新生代),必须考虑到这个区域的对象完全有可能被其他区域的对象所引用,这时候就需要一并将关联的区域对象也加入GC Roots集合中去考虑,才能保证可达性分析的准确性。但是这样就需要把老年代的对象也作为Roots了,扫描代价太大。于是我们引入了记忆集

(延伸,跨代引用):也就是一个代中的对象引用另一个代中的对象
跨代引用假说:跨代引用相对于同代引用来说只是极少数
隐含推论:存在互相引用关系的两个对象,是应该倾向于同时生存或同时消亡的

(延伸,解决跨代引用)
记忆集(Remembered Set):一种用于记录从非收集区域指向收集区域的指针集合的抽象数据结构。在对象层面来说就是非收集区域对象对收集区域对象的引用的记录。这样被非收集区域引用的收集区域对象是不能回收的。
字长精度:每个记录精确到一个机器字长,该字包含跨代指针
对象精度:每个记录精确到一个对象,该对象里有字段含有跨代指针
卡精度:每个记录精确到一块内存区域,该区域内有对象含有跨代指针

卡表(Card Table):是记忆集的卡精度的具体实现,1个字节表示512字节。CARD_TABLE [this address >> 9] = 0;
卡表的每个元素都对应着其标识的内存区域中一块特定大小的内存块,这个内存块称为卡页(Card Page)。具体点,记录了新生代中哪些位置存在被老年代引用的对象,这样把之前判断的可回收对象集合中去除这些不可回收的即可,无需再扫描老年代。
(延伸,卡表如何维护)
在hotspot实现中,用的是写屏障技术
写屏障可以看成是jvm对于 引用类型字段赋值 这个动作的AOP。因此可以区分写前屏障,写后屏障,从而实现当对象状态改变后,维护卡表状态。

快速判断是否是root的小技巧:
由于Root采用栈方式存放变量和指针,所以如果一个指针,它保存了堆内存里面的对象,但是自己又不存放在堆内存里面,那它就是一个Root

加速枚举roots?

每次都要从栈中查找,有个缺点,慢,于是引入了oopmap这个数据结构(ordinary object pointer普通对象指针,oopmap就是存放这些指针的map)记录栈中引用数据类型的位置,这样就不用每次重新找,直接用就行,我们所需的是维护这个oopmap。这是一种空间换时间的做法。但是不是每一行的代码后都会去更新,更新这个数据结构的位置被叫做安全点。

对于一些在休眠或者阻塞的线程,我们不可能让他们继续执行到下一个安全点,那么hotspot就引入了安全区域的概念。如果某一段代码,没有改变引用关系,那么这段代码叫做安全区域,显然在安全区域的任何一个位置开始枚举gcroots都是可以的。当用户线程进入安全区域后,用户线程会标识自己已经进入了安全区域,那么jvm在进行垃圾收集时就不会考虑这些已经进入安全区域的线程,当用户线程即将离开安全区域时,会查询jvm是否即将进行垃圾回收,检查系统是否已经完成了GC或者根节点枚举(这个跟GC的算法有关系),简洁点说是否在STW状态。

如果不是,那么用户线程就继续执行,否则它将会等待垃圾回收完成。如果是,它就必须等待收到可以安全离开安全区域的Safe Region的信号为止。

(延伸,什么叫改变引用关系?比如?)

栈中对象弹出

(延伸,安全点)那么如何保证GC的时候所有线程都处于安全点呢?

抢先式中断:在需要进行GC时,程序首先会中断所有的用户线程,然后判断用户线程是否停在了安全点上,如果没有则让对应的线程恢复执行,等待执行到了安全点上再进行等待。(这种方法现在已经没有虚拟机使用)
主动式中断:在需要进行GC时,程序会设置一个标志位,表示马上需要执行垃圾回收了,对应的用户线程在执行时,会不断地轮询这个标志位,如果中断标志位为真,那么程序会在执行到下一个安全点时自动中断挂起。

(延伸)安全点一般设立在什么位置?

安全点位置的选取基本上是以“是否具有让程序长时间执行的特征”为标准进行选定的,注意不是某一条指令执行久,因为每条指令执行的时间都非常短暂,程序不太可能因为指令流长度太长这样的原因而长时间执行,长时间执行”的最明显特征就是指令序列的复用,例如方法调用、循环跳转、异常跳转等都属于指令序列复用,所以只有具有这些功能的指令才会产生安全点。

更个人的理解

首先需要理解好安全点究竟是什么。有的时候用的是函数调用的时候产生的可做root对象,导致需要更新oopmap,因此要设立安全点。

说白了就是避免长时间不更新。

回收算法

GC 的三种收集方法:标记清除、标记整理、复制算法的原理与特点

标记-清除

这是垃圾收集算法中最基础的,根据名字就可以知道,它的思想就是标记哪些要被回收的对象,然后统一回收。这种方法很简单,但是会有两个主要问题:1.效率不高,标记和清除的效率都很低(为什么?其实是和其他算法比较出来的);2.会产生大量不连续的内存碎片,导致以后程序在分配较大的对象时,由于没有充足的连续内存而提前触发一次 GC 动作。

实际上,经过多次标记才会被清除。

复制算法

为了解决效率问题,复制算法将可用内存按容量划分为相等的两部分,然后每次只使用其中的一块,当一块内存用完时,就将还存活的对象复制到第二块内存上,然后一次性清除完第一块内存。但是这种方式,占内存的代价太高,要浪费一半的内存,且需要大量赋值操作。

(延伸,改进)

于是将该算法进行了改进,内存区域不再是按照 1:1 去划分,而是将内存划分为8:1:1 三部分,较大那份内存交 Eden 区,其余是两块较小的内存区叫 Survior 区。

每次都会优先使用 Eden 区,若 Eden 区满,就将Eden和第一块Survior(S0)中存活的对象复制到第二块小内存区上(S1),如果此时存活的对象太多,以至于这第二块 Survivor 不够时,会将这些对象通过分配担保机制复制到老年代中。(java 堆又分为新生代和老年代)

(延伸,分配担保)

在发生MinorGC前,JVM会检查老年代的最大可用的连续空间,是否大于新生代所有存活对象的总空间,如果大于,可以确保MinorGC是安全的。

如果小于,那么VM会检查是否设置了允许担保失败,如果允许,则继续检查老年代最大可用的连续空间,是
否大于历次晋升到老年代对象的平均大小,如果大于,则尝试进行一次MinorGC。如果不大于,则改做一次Full GC

(延伸,新生代,老年代)

Minor GC 清理新生代。Major GC是清除老年代。 Full GC 是清理整个堆空间—包括新生代和老年代。

标记-整理

该算法主要是为了解决标记-清除,产生大量内存碎片的问题;当对象存活率较高时,也解决了复制算法的效率问题。它的不同之处就是在清除对象的时候现将可回收对象移动到一端,然后清除掉端边界以外的对象,这样就不会产生内存碎片了。

老年代一般不用复制算法(对象存活率较高时,效率低)

(延伸,现在的jdk8是怎么做的)

现在的虚拟机垃圾收集大多采用这种方式,它根据对象的生存周期,将堆分为新生代和老年代。在新生代中,由于对象生存期短,每次回收都会有大量对象死去,那么这时就采用复制算法。老年代里的对象存活率较高,没有额外的空间进行分配担保,所以可以使用标记-整理 或者 标记-清除

描述一个收集器的几个属性

从收集的内容划分

◆MinorGC/YoungGC:发生在新生代的收集动作
◆MajorGC/OldGC:发生在老年代的GC,目前只有CMS收集器会有单独收集老年代的行为
◆MixedGC:收集整个新生代以及部分老年代,目前只有G1收集器会有这种行为

◆FullGC:收集整个Java堆和方法区的GC

从收集的动作划分

串行收集:GC单线程内存回收、会暂停所有的用户线程如:Serial
并行收集:多个GC线程并发工作,此时用户线程是暂停的,如:Parallel
并发收集:用户线程和GC线程同时执行(不一定是并行可能交替执行)),不需要停顿用户线程,如:CMS

垃圾收集器-实现算法

image-20220623110814750

串行收集器

新生代采用复制算法,老年代使用标记整理算法,都会暂停所有用户线程(STW),

优点是简单,对于单cpu,由于没有多线程的交互开销,可能更高效,是默认的Client模式下的新生代收集器
使用-XX:+UseSerialGC来开启,会使用:Serial+SerialOld的收集器组合

(延伸)Stop-The-World

◆STW是java中一种全局暂停的现象,多半由于GC引起。所谓全局停顿,就是所有ava代码停止运行,native代码可以执行,但不能和VM交互
◆其危害是长时间服务停止,没有响应;对于HA系统,可能引起主备切换,严重危害生产环境

image-20220623110942771

并行收集器

新生代使用复制算法

在并发能力好的CPU环境里,它停顿的时间要比串行收集器短;但对于单cpu或并发能力较弱的CPU,由于多线程的交互开销,可能比串行回收器更差,是Servert模式下首选的新生代收集器,且能和CMS收集器配合使用,只要使用CMS,自动就是用了Parnew,不再使用-XX:+UseParNewGC:来单独开启。

-XX:ParallelGCThreads:指定线程数,最好与CPU数量一致

image-20220623111442565

新生代Parallel Scavenge(/ˈskævɪndʒ/)收集器/Parallel Old收集器:

是一个应用于新生代的、使用复制算法的、并行的收集器
跟ParNew很类似,但更关注吞吐量,能最高效率的利用CPU,适合运行后台应用。

与cms的区别在于老年代采用标记整理算法。

使用-XX:+UseParallelGC来开启
使用-XX:+UseParallelOldGC来开启老年代使用ParallelOld收集器,等价于使用Parallel Scavenge+Parallel Old的收集器组合

-XX:MaxGCPauseMillis:设置GC的最大停顿时间

image-20220623111944140

CMS(Concurrent Mark and Sweep并发标记清除)

收集器分为:

初始标记:只标记GC Rootsi能直接关联到的对象

并发标记:进行GC Roots Tracing的过程
重新标记:修正并发标记期间,因程序运行导致标记发生变化的那一部分对象。增量更新法,如果修改的是从已经扫描过得开始的引用,则记录下来这些。重新扫描。
并发清除:并发回收垃圾对象

在初始标记和重新标记两个阶段还是会发生Stop-the-World
使用标记清除算法,多线程并发收集的垃圾收集器
最后的重置线程,指的是清空跟收集相关的数据并重置,为下一次收集做准备。

优点:低停顿、并发执行
缺点:
并发执行,对CPU资源压力大;

无法处理在处理过程中产生的垃圾,可能导致FullGC
采用的标记清除算法会导致大量碎片,从而在分配大对象时可能触发FullGC。

开启:-XX:UseConcMarkSweepGC:使用ParNew+CMS+Serial Old的收集器组合,Serial Old将作为CMS出
错的后备收集器
-XX:CMSInitiatingOccupancyFraction:设置CMS收集器
在老年代空间被使用多少后触发回收,默认80%

image-20220623112608722

G1(Garbage-First)收集器:

是一款面向服务端应用的收集器,与其它收集器相比,具有如下特点:
1.G1把内存划分成多个独立的区域(Region)
2.G1仍采用分代思想,保留了新生代和老年代,但它们不再是物理隔离的,而是一部分Regionl的集合,且不
需要Region是连续的

3.G1能充分利用多CPU、多核环境硬件优势,尽量缩短STW
4.G1整体上采用标记整理算法,局部是通过复制算法,不会产生内存碎片
5.G1的停顿可预测,能明确指定在一个时间段内,消耗在垃圾收集上的时间不能超过多长时间

6.Gl跟踪各个Region里面垃圾堆的价值大小,在后台维护一个优先列表,每次根据允许的时间来回收价值最大的区域,从而保证在有限时间内的高效收集

跟CMS类以,也分为四个阶段:

初始标记:只标记GCRootsi能直接关联到的对象
并发标记:进行GC Roots Tracing的过程

最终标记:修正并发标记期间,因程序运行导致标记发生变化的那一部分对象。原始记录法。记录删除的,并以他们为roots重新搜索。可能会少量确实应该回收的没有回收。
筛选回收:根据时间来进行价值最大化的回收。

使用和配置G1:-XX:+UseG1GC:开启G1,默认就是G1
-XX:MaxGCPauseMillis=n:最大GC停顿时间,这是个软目标,VM将尽可能(但不保证)停顿小于这个时间
-XX:InitiatingHeapOccupancyPercent:=n:堆占用了多少的时候就触发GC,默认为45

-XX:NewRatio=n:默认为2
-XX:SurvivorRatio=n:默认为8
-XX:MaxTenuringThreshold=n:新生代到老年代的岁数,G1默认是15,cms是6

-XX:ParallelGCThreads=n:并行GC的线程数,默认值会根据平台不同而不同
-XX:ConcGCThreads=n:并发GC使用的线程数
-XX:G1ReservePercent:=n:设置作为空闲空间的预留内存百分比,以降低目标空间溢出的风险,默认值是10%

-XX:G1HeapRegionSize=n:设置的Gl区域的大小。值是2的幂,范围是1MB到32MB。目标是根据最小的java堆大
小划分出约2048个区域。32*2048=64G,.当然2048不是固定的,如果是128G内存,n=32M,则会变为4096

image-20220623113314515

ZGC收集器

JDK11加入的具有实验性质的低延迟收集器
ZGC的设计目标是:支持TB级内存容量,暂停时间低<10ms),对整个程序吞吐量的影响小于15%
ZGC里面的新技术:着色指针和读屏障

(延伸,着色指针)java对象头,锁

jdk8 jdk13默认收集器

java -XX:+PrintCommandLineFlags -version 可以看到UseParallelGC

java -XX:+PrintGCDetails -version

jdk8 默认Parallel Scavenge + Parallel Old

jdk13 默认G1

GC性能指标

吞吐量=应用代码执行的时间/运行的总时间
GC负荷,与吞吐量相反,是GC时间/运行的总时间
暂停时间,就是发生Stop-the-World的总时间

GC频率,就是GC在一个时间段发生的次数
反应速度,就是从对象成为垃圾到被回收的时间。交互式应用通常希望暂停时间越少越好

JVM内存配置原则

依据对象的存活周期进行分类,对象优先在新生代分配,长时间存活的对象进入老年代.

新生代尽可能设置大点,3/8到一半,如果太小会导致
1.YGC次数更加频繁
2.可能导致YGC后的对象进入老年代,如果此时老年代满了,会触发FGC

根据不同代的特点,选取合适的收集算法:少量对象存活适合复制算法;大量对象存活,适合标记清除或者标记整理.

很奇怪?

对老年代,针对响应时间优先的应用:由于老年代通常采用并发收集器,因此其大小要综合考虑并发量和并发持续时间等参数
如果空间设置小了,可能会造成内存碎片,高回收频率会导致应用暂停
如果空间设置大了,会需要较长的回收时间

对老年代,针对吞吐量优先的应用:通常设置较大的新生代和较小的老年代,这样可以尽可能回收大部分短期对象,减少中期对象,而老年代尽量存放长期存活的对象

Minor GC 与 Full GC 分别在什么时候发生?

新生代内存不够用时候发生 MGC 也叫 YGC,JVM 内存不够的时候发生 FGC

几种常用的内存调试工具:jmap、jstack、jconsole、jhat

jstack 可以看当前栈的情况,jmap 查看内存,jhat 进行 dump 堆的信息

mat(eclipse 的也要了解一下)

引用分类,如何利用

对于程序员来说如何去控制内存回收?

强引用:类似于Object a=newA0这样的,不会被回收

软引用:还有用但并不必须的对象。用SoftReference来实现软引用

弱引用:非必须对象,比软引用还要弱,垃圾回收时会回收掉。用NeakReference来实现弱引用

虚引用:也称为幽灵引用或幻影引用,是最弱的引用。垃圾回收时会回收掉。用PhantomReference:来实现虚引用

有gc(垃圾回收)为什么还会oom?

首先内存泄漏与内存溢出oom是两个概念。

内存泄漏是自己忘记释放一块内存,内存溢出是已经使用内存超出最大限制。

内存泄漏只是一种导致oom的原因,如果问内存溢出,答案少一点,如果问oom答案会更多。

先说除了内存泄漏,其他会导致oom的原因,比如强行申请并使用(这个使用是细节)很大的空间,会直接oom。亦或者开的进程过多,无法通过页面置换回磁盘的临时数据越来越多,也会导致oom。

接下来只说内存泄漏的原因。

真正正在使用的内存-实际不需要但仍有链接的内存-实际不需要也无链接的内存-没有申请过的空闲内存。c++的话,不需要且无指向的内存不会释放,jvm的gc帮我们释放的也正是这块。但是对于实际不需要但仍有链接的内存,gc也没有办法。

gc为什么还会内存泄漏的这个问题其实等价于:“为什么我请了佣人来收拾房间,我的房间还是会堆满?那我还请佣人来干什么?他不是号称能把我房间里的垃圾都清理干净的么?”

问题是如果您房间里堆的都是宝贝(或者看起来都是宝贝)的话,佣人也没辙啊。

引用的本质

java引用的本质是jvm托管的指针,c++的引用是常指针

汇编层,c++的指针和引用写一段代码,编译成汇编后是一样的

进程与线程-并发

多进程多线程

简述程序、进程、线程的基本概念。以及他们之间关系是什么

提到这几个概念不得不从高并发历史讲起。

程序是含有指令和数据的文件,被存储在磁盘或其他的数据存储设备中,也就是说程序是静态的代码。

当一个程序加载到操作系统中开始运行,就变成了进程,是操作系统进行资源分配的的基本单位,实现了操作系统的并发

(延伸,进程控制块)PCB包含四大部分,

进程描述信息,pid,uid。

进程控制和管理信息,如进程当前状态,进程优先级,代码运行入口,程序外存地址,进入内存时间,cpu占用时间,信号量使用。

资源分配清单,如代码段,数据段,堆栈段的指针,文件描述符,键盘,鼠标。

cpu上下文,通用寄存器,地址寄存器,控制寄存器,标志寄存器,状态字。

后期又出现了线程的概念,并且规定每个进程至少有一个线程,也被成为主线程,是CPU调度的基本单位,实现进程内部的并发。每个线程都独自占用一个虚拟处理器:独自的寄存器组指令计数器和处理器状态。每个线程完成不同的任务,但是共享同一地址空间(操作系统给每一个进程分配的那一部分,也就是同样的动态内存,映射文件,目标代码等等),打开的文件队列和其他内核资源

区别:

  1. 一个线程只能属于一个进程,而一个进程可以有多个线程,但至少有一个线程。线程依赖于进程而存在。
  2. 进程在执行过程中拥有独立的内存单元,而多个线程共享进程的内存。(资源分配给进程,同一进程的所有线程共享该进程的所有资源。同一进程中的多个线程共享代码段(代码和常量),数据段(全局变量和静态变量),扩展段(堆存储)。但是每个线程拥有自己的栈段,栈段又叫运行时段,用来存放所有局部变量和临时变量。)
  3. 进程是资源分配的最小单位,线程是CPU调度的最小单位
  4. 系统开销: 由于在创建或撤消进程时,系统都要为之分配或回收资源,如内存空间、I/o设备等。因此,操作系统所付出的开销将显著地大于在创建或撤消线程时的开销。类似地,在进行进程切换时,涉及到整个当前进程CPU环境的保存以及新被调度运行的进程的CPU环境的设置。而线程切换只须保存和设置少量寄存器的内容,并不涉及存储器管理方面的操作。可见,进程切换的开销也远大于线程切换的开销
  5. 通信:由于同一进程中的多个线程具有相同的地址空间,致使它们之间的同步和通信的实现,也变得比较容易。进程间通信IPC,线程间可以直接读写进程数据段(如全局变量)来进行通信——需要进程同步和互斥手段的辅助,以保证数据的一致性。在有的支持用户态线程的系统中,线程的切换、同步和通信都无须操作系统内核的干预 ,所谓的协程?
  6. 进程编程调试简单可靠性高,但是创建销毁开销大;线程正相反,开销小,切换速度快,但是编程调试相对复杂
  7. 进程间不会相互影响 ;线程一个线程挂掉将导致整个进程挂掉
  8. 进程适应于多核、多机分布;线程适用于多核

进程切换与线程切换

如果从创建和撤销的角度考虑:

最大区别在于资源分配。

如果从cpu调度的角度考虑:

很显然,他们都需要切换寄存器组。真正重要的区别在于:虚拟地址,不同进程的虚拟地址不同,而同一进程的不同线程,共享同一虚拟地址。虚拟地址需要转化为物理地址。

cpu有

图片

延伸(项目,python 全局解释器锁)

说到多线程多进程,不得不提我做的一个项目,也就是简历上的第二个项目,eve手游数据采集分析,其中最大的难点就在于效率,如何并发的采集和处理图像,这个属于计算密集型操作,那么利用多核优势就十分重要了。

多线程优点,cpu并行,io并行。python的多线程适合io并行问题

我一开始打算用多线程抓图,后来发现不行。为什么呢?

因为采集和分析用的是python,而python和c和java相比有一个很重要的特点。python里一个进程永远只能同时执行一个线程(拿到GIL的线程才能执行)。GIL是global interpreter lock全局解释器锁。他是以前在单核CPU上调度多个线程任务引入的。哦对了,这里说的python解释器是CPython,有的解释器没有GIL哈。GIL的存在导致无法利用多核优势。

有GIL并不意味着python一定是线程安全的。一个线程有两种情况下会释放全局解释器锁,一种情况是在该线程进入IO操作之前,会主动释放GIL,另一种情况是解释器不间断运行了1000字节码(Py2)或运行15毫秒(Py3)后,该线程也会放弃GIL。既然一个线程可能随时会失去GIL,那么这就一定会涉及到线程安全的问题。比方说读完一个全局变量后,准备进行接下来的修改操作时失去了GIL,同时其他线程修改了这个全局变量,就会导致线程不安全。

延伸(用户态线程,内核态线程)

cpu实际有4个状态哦,linux用了2个

用户态的,和内核态的,分界点仍然是API,调用api的部分就是用户态线程,而API的执行部分,就是处于os内,属于内核态。这样这个代码的执行就分在两个线程中,协作完成。注意既然已经在不同的线程中完成,对应的堆栈资源也就是不同的。

用户线程一般不会直接去使用内核线程,而是去使用内核线程的一种高级接口——轻量级进程(Light Weight Process,LWP)。因此对于用户线程来说,用户程序必须让它的调度器采用用户线程,然后在内核线程上运行它。

用户线程与内核线程的映射关系有三种模型:一对一模型、多对一模型、多对多模型。

Java SE最常用的JVM是Oracle/Sun(甲骨文公司收购了Sun)研发的HotSpot VM。在这个JVM所支持的所有平台上都是采用一对一的线程模型的,除了Solaris平台。

一般提到的多线程实际上都是内核态线程。内核态线程实际上是想强调由操作系统管理调度的线程,并不是执行的代码处于内核态

协程-用户态线程

而协程则是用户态线程,由用户在用户栈中中分配执行栈空间。用户做调度。在操作系统眼里就是一个线程。比较像多对一模型

image-20220512085950376

在网络io中,可以通过epoll实现协程,属于特定领域应用,可以将业务代码与事件循环解耦合。

多路复用

select linux有一个文件描述符表(有进程专属的,也有操作系统全局的,最后其实链接到inode),打开的套接字会记录在文件描述符表中,套接字实际在内核中,有一个读缓冲区和一个写缓冲区,用户线程要读需要拷贝数据,写数据也是要先写入写缓冲区。那么问题来了,如果读缓冲区空的,写缓冲区满了,怎么办?这是就引出来阻塞io,非阻塞式io,多路复用,阻塞式io每一个套接字用一个线程,等待的时候让出cpu,非阻塞io,则是忙等待,用read轮询,这样又会造成空耗cpu,本质原因是我们不知道什么时候会就绪,那么谁知道?操作系统呀,它知道,也就是多路复用,让操作系统通知我们,这样就能避免两头忙,一个线程可以监听多个套接字,linux下有三种实现,select,poll,epoll。

image-20220512151053857

select等待或者超时会返回,支持监听可读可写异常三类事件

fd_set是个unsigned long型的数组,共16个元素,每一位对应一个fd,于是最多可以监听64位(long 在64位是8个字节)*16=1024个

而且每次调用需要传递所有fd集合,会导致频繁拷贝数据

而且就绪了,也需要遍历整个集合来判断哪个是就绪。

poll,引入了pollfd结构体,我们可以传任意大小的结构体数组进去,解决了第一个问题,最大支持的fd个数为最多可打开的文件描述符数目,实际上默认进程可打开的fd数为1024,但可以修改。

cat /proc/sys/fs/file-max # 查看系统全局的设置
cat /proc/sys/fs/file-nr # 查看系统全局已使用的fd数 分别为:已经分配的文件句柄数,已经分配但没有使用的文件句柄数,最大文件句柄数
echo 1000000 > /proc/sys/fs/file-max # 临时修改
#永久修改 在/etc/sysctl.conf文件中设置 fs.file-max=100000
ulimit -n # 查看单个进程最大打开文件描述符数
ulimit -SHn 1000000# 临时修改
#永久修改 /etc/security/limits.conf 添加 soft nofile 1000000 hard nofile 1000000
# 注意配合修改 echo 2000000 > /proc/sys/fs/nr_open 需要不比nofile小

image-20220512151257116

epoll

epoll没有上限。

而且可以只传一个,先添加一个监听socket,如有连接到来,则用epoll_ctl新增一个到epoll里,并且epoll_wait返回的时候只返回就绪的socket列表。减少了内核的拷贝数据量,并且不用遍历获取就绪

image-20220512155636644

epoll的三个函数

1.epoll_create的时候在内核cache里会额外建了个红黑树用于存储以后epoll_ctl传来的socket支持快速查找插入删除,还会再建立一个list链表,用于存储准备就绪的事件。

2.epoll_ctl用于把socket放到epoll文件系统里file对象对应的红黑树上,还会给内核中断处理程序注册一个回调函数,告诉内核,如果这个fd的中断到了,就把它放到准备就绪list链表里。所以,当一个socket上有数据到了,内核在把网卡上的数据copy到内核中后就来把socket插入到准备就绪链表里了。

3.epoll_wait查看在create阶段创建的list列表是否有数据,有数据就返回,没有就等待,所以是O(1)

kqueue

kqueue和epoll一样,都是用来替换select和poll的。不同的是kqueue被用在FreeBSD,NetBSD, OpenBSD, DragonFly BSD, 和 macOS等类unix中。

evport用于Solaris 10系统
epoll用于linux
kqueue用于os x,freebsd

select=通常作备选安装在所有平台上

水平触发与边缘触发

水平触发(level-trggered)

  • 只要文件描述符关联的读内核缓冲区非空,有数据可以读取,就一直发出可读信号进行通知,
  • 当文件描述符关联的内核写缓冲区不满,有空间可以写入,就一直发出可写信号进行通知

边缘触发(edge-triggered)

  • 当文件描述符关联的读内核缓冲区由空转化为非空的时候,则发出可读信号进行通知,
  • 当文件描述符关联的内核写缓冲区由满转化为不满的时候,则发出可写信号进行通知
区别

水平触发是只要读缓冲区有数据,就会一直触发可读信号,而边缘触发仅仅在空变为非空的时候通知一次,举个例子:

  1. 读缓冲区刚开始是空的
  2. 读缓冲区写入2KB数据
  3. 水平触发和边缘触发模式此时都会发出可读信号
  4. 收到信号通知后,读取了1kb的数据,读缓冲区还剩余1KB数据
  5. 水平触发会再次进行通知,而边缘触发不会再进行通知

所以边缘触发需要一次性的把缓冲区的数据读完为止,也就是一直读,直到读到EGAIN(EGAIN说明缓冲区已经空了)为止,因为这一点,边缘触发需要设置文件句柄为非阻塞。

ET模式在很大程度上减少了epoll事件被重复触发的次数,因此效率要比LT模式高。epoll工作在ET模式的时候,必须使用非阻塞套接口,以避免由于一个文件句柄的阻塞读/阻塞写操作把处理多个文件描述符的任务饿死。

ET模式下每次write或read需要循环write或read直到返回EAGAIN错误。以读操作为例,这是因为ET模式只在socket描述符状态发生变化时才触发事件,如果不一次把socket内核缓冲区的数据读完,会导致socket内核缓冲区中即使还有一部分数据,该socket的可读事件也不会被触发
根据上面的讨论,若ET模式下使用阻塞IO,则程序一定会阻塞在最后一次write或read操作,因此说ET模式下一定要使用非阻塞IO

这里只简单的给出了水平触发与边缘触发的处理方式的不同,边缘触发相对水平触发处理的细节更多一些,

//水平触发
ret = read(fd, buf, sizeof(buf));

//边缘触发(代码不完整,仅为简单区别与水平触发方式的代码)
while(true) {
    ret = read(fd, buf, sizeof(buf);
    if (ret == EAGAIN) break;
}

线程安全

定义:指某个函数、函数库在多线程环境中被调用时,能够正确地处理多个线程之间的公用变量(如类成员变量),使程序功能正确完成。 那么这个函数称作是线程安全的

那什么时候会造成线程安全问题呢?当多个线程同时去访问一个变量。

解决,如果一定多个线程要直接访问某一个值,那只能用锁。否则可以用ThreadLocal。

悲观锁

悲观锁,顾名思义它是悲观的。讲得通俗点就是,认为自己在使用数据的时候,一定有别的线程来修改数据,因此在获取数据的时候先加锁,确保数据不会被线程修改。形象理解就是总觉得有刁民想害朕。

总是假设最坏的情况,每次取数据时都认为其他线程会修改,所以都会加(悲观)锁。一旦加锁,不同线程同时执行时,只能有一个线程执行,其他的线程在入口处等待,直到锁被释放。

悲观锁在MySQLJava有广泛的使用

  • MySQL的读锁、写锁、行锁等

  • Javasynchronized关键字

死锁四个必要条件

(1) 互斥:一次只有一个进程可以使用一个资源。其他进程不能访问已分配给其他进程的资源。

(2)占有且等待:当一个进程在等待分配得到其他资源时,其继续占有已分配得到的资源。

(3)非抢占:不能强行抢占进程中已占有的资源。

(4)循环等待:存在一个封闭的进程链,使得每个资源至少占有此链中下一个进程所需要的一个资源。

死锁的预防、检测、避免、解除

乐观锁

wait,notify,实现生产者消费者,

而乐观锁就比较乐观了,认为在使用数据时,不会有别的线程来修改数据,就不会加锁,只是在更新数据的时候去判断之前有没有别的线程来更新了数据。

一般会使用版本号机制CAS(compare and swap)算法实现。

  • 取出记录时,获取当前version
  • 更新时,带上这个version
  • 执行更新时, set version = newVersion where version = oldVersion
  • 如果version不对,就更新失败

核心SQL:

update tablename set name = 'Aron', version = version + 1 where id = #{id} and version = #{version};  

如果想在mybatis中使用

首先表中加一列version,int类型的。

在对应的实体类中添加 version 属性,并且在这个属性上面添加 @Version 注解

添加一个配置类,optimisticLockerInterceptor函数返回OptimisticLockerInterceptor对象

详细

乐观锁的另一种技术技术CAS(compare and swap)也叫无锁、自旋锁、乐观锁、轻量级锁,当多个线程尝试使用CAS同时更新同一个变量时,只有其中一个线程能更新变量的值,而其它线程都失败,失败的线程并不会被挂起,而是被告知这次竞争中失败,并可以再次尝试。

CAS 操作是一个原子操作中包含三个操作数 :

  • 需要读写的内存位置V
  • 进行比较的预期原值A
  • 拟写入的新值B

如果内存位置V的值与预期原值A相匹配,那么处理器会自动将该位置值更新为新值B。否则处理器不做任何操作。无论哪种情况,它都会在 CAS 指令之前返回该位置的值(在 CAS 的一些特殊情况下将仅返回 CAS 是否成功,而不提取当前值)。CAS 有效地说明了“ 我认为位置 V 应该包含值 A;如果包含该值,则将 B 放到这个位置;否则,不要更改该位置,只告诉我这个位置现在的值即可。

(延伸)实例-concurrent包的实现

volatile 关键字是一种类型修饰符,用它声明的类型变量表示可以被某些编译器未知的因素更改,比如:操作系统、硬件,内嵌汇编操纵栈或者其它线程等。遇到这个关键字声明的变量,编译器对访问该变量的代码就不再进行优化,从而可以提供对特殊地址的稳定访问。

解决了多线程变量可见性问题,并发访问共享变量时,一个线程改变了变量的值,怎样让改变后的值对其它线程 visible

当要求使用 volatile 声明的变量的值的时候,系统总是重新从它所在的内存读取数据,即使它前面的指令刚刚从该处读取过数据。而且读取的数据立刻被保存。

由于javaCAS同时具有 volatile 读和volatile写的内存语义,因此Java线程之间的通信现在有了下面四种方式:

  1. A线程写volatile变量,随后B线程读这个volatile变量。
  2. A线程写volatile变量,随后B线程用CAS更新这个volatile变量。
  3. A线程用CAS更新一个volatile变量,随后B线程用CAS更新这个volatile变量。
  4. A线程用CAS更新一个volatile变量,随后B线程读这个volatile变量。

JavaCAS会使用现代处理器上提供的高效机器级别原子指令,这些原子指令以原子方式对内存执行读-改-写操作,这是在多处理器中实现同步的关键(从本质上来说,能够支持原子性读-改-写指令的计算机器,是顺序计算图灵机的异步等价机器,因此任何现代的多处理器都会去支持某种能对内存执行原子性读-改-写操作的原子指令)。同时,volatile变量的读/写和CAS可以实现线程之间的通信。把这些特性整合在一起,就形成了整个concurrent包得以实现的基石。

仔细分析concurrent包的源代码实现,会发现一个通用化的实现模式:

  1. 首先,声明共享变量为volatile;  
  2. 然后,使用CAS的原子条件更新来实现线程之间的同步;
  3. 同时,配合以volatile的读/写和CAS所具有的volatile读和写的内存语义来实现线程

缺点

  • ABA问题

如果一个线程t1正修改共享变量的值A,但还没修改,此时另一个线程t2获取到CPU时间片,将共享变量的值A修改为B,然后又修改为A,此时线程t1检查发现共享变量的值没有发生变化,但是实际上却变化了。
解决办法: 使用版本号,在变量前面追加上版本号,每次变量更新的时候把版本号加1,那么A-B-A 就会变成1A-2B-3A。从Java1.5开始JUC包里提供了一个类AtomicStampedReference来解决ABA问题。AtomicStampedReference类的compareAndSet方法作用是首先检查当前引用是否等于预期引用,并且当前版本号是否等于预期版本号,如果全部相等,则以原子方式将该引用和该标志的值设置为给定的更新值。

(2)循环时间长开销会比较大:自旋重试时间,会给CPU带来非常大的执行开销

(3)只能保证一个共享变量的原子操作,不能保证同时对多个变量的原子性操作
解决办法:
从Java1.5开始JDK提供了AtomicReference类来保证引用对象之间的原子性,你可以把多个变量放在一个对象里来进行CAS操作

  • 循环时间长开销大

自旋CAS(不成功,就一直循环执行,直到成功)如果长时间不成功,会给CPU带来非常大的执行开销。如果JVM能支持处理器提供的pause指令那么效率会有一定的提升,pause指令有两个作用,第一它可以延迟流水线执行指令(de-pipeline),使CPU不会消耗过多的执行资源,延迟的时间取决于具体实现的版本,在一些处理器上延迟时间是零。第二它可以避免在退出循环的时候因内存顺序冲突(memory order violation)而引起CPU流水线被清空(CPU pipeline flush),从而提高CPU的执行效率。

内存顺序冲突一般是由假共享引起,假共享是指多个CPU同时修改同一个缓存行(缓存的最小操作单位)的不同部分而引起其中一个CPU的操作无效,当出现这个内存顺序冲突时,CPU必须清空流水线。

(延伸,cpu流水线)

将指令分解为多步,并让不同指令的各步操作重叠,从而实现几条指令并行处理,以加速程序运行过程的技术。

五级流水线,取址,译码,执行,访存,写回.

如果第二条指令在第一条指令写回前访存,就可能会出问题

采用流水线技术后,并没有加速单条指令的执行,每条指令的操作步骤一个也不能少,只是多条指令的不同操作步骤同时执行,因而从总体上看加快了指令流速度,缩短了程序执行时间。

  • 只能保证一个共享变量的原子操作

当对一个共享变量执行操作时,我们可以使用循环CAS的方式来保证原子操作,但是对多个共享变量操作时,循环CAS就无法保证操作的原子性,这个时候就可以用锁,或者有一个取巧的办法,就是把多个共享变量合并成一个共享变量来操作。比如有两个共享变量i = 2,j = a,合并一下ij = 2a,然后用CAS来操作ij。从Java 1.5开始JDK提供了AtomicReference类来保证引用对象之间的原子性,你可以把多个变量放在一个对象里来进行CAS操作。

读的多,冲突几率小,乐观锁。 写的多,冲突几率大,悲观锁。

如何做到线程安全

请描述避免多线程竞争时有哪些手段?

  1. 不可变对象;

  2. 互斥锁;(乐观锁cas,悲观锁synchronized,Lock)

    管程和信号量都能解决并发问题,它们是等价的。所谓等价指的是用管程能够实现信号量,也能用信号量实现管程。但是管程在信号量的基础上提供条件同步,使用更容易,所以 Java 采用的是管程技术。synchronized 关键字及 wait()、notify()、notifyAll() 这三个方法都是管程的组成部分。

  3. ThreadLocal 对象;

  • 方法一:使用synchronized关键字,一个表现为原生语法层面的互斥锁,它是一种悲观锁,使用它的时候我们一般需要一个监听对象 并且监听对象必须是唯一的,通常就是当前类的字节码对象。它是JVM级别的,不会造成死锁的情况。使用synchronized可以拿来修饰类,静态方法,普通方法和代码块。比如:Hashtable类就是使用synchronized来修饰方法的。put方法部分源码:

    public synchronized V put(K key, V value) {
          // Make sure the value is not null
          if (value == null) {
              throw new NullPointerException();
          } 
    }

    而ConcurrentHashMap类中就是使用synchronized来锁代码块的。putVal方法部分源码:

    else {
                  V oldVal = null;
                  synchronized (f) {
                      if (tabAt(tab, i) == f) {
                          if (fh >= 0) {
                              binCount = 1;
                          }
                      }
                  }
          }

    synchronized关键字底层实现主要是通过monitor enter 与monitor exit计数 ,如果计数器不为0,说明资源被占用,其他线程就不能访问了,但是可重入的除外。

    (延伸,可重用锁)

    说到这,就来讲讲什么是可重入的。这里其实就是指的可重入锁:指的是同一线程外层函数获得锁之后,内层递归函数仍然有获取该锁的代码,但不受影响,执行对象中所有同步方法不用再次获得锁。避免了频繁的持有释放操作,这样既提升了效率,又避免了死锁。

    java里面内置锁(synchronize)和Lock(ReentrantLock)都是可重入锁。

    原理:

    加锁时,需要判断锁是否已经被获取。如果已经被获取,则判断获取锁的线程是否是当前线程。如果是当前线程,则给获取次数加1。如果不是当前线程,则需要等待。

    释放锁时,需要给锁的获取次数减1,然后判断,次数是否为0了。如果次数为0了,则需要调用锁的唤醒方法,让锁上阻塞的其他线程得到执行的机会。

    (延伸,锁升级原理)其实在使用synchronized时,存在一个锁升级原理。它是指在锁对象的对象头里面有一个 threadid 字段,在第一次访问的时候 threadid 为空,jvm 让其持有偏向锁,并将 threadid 设置为其线程 id,再次进入的时候会先判断 threadid 是否与其线程 id 一致,如果一致则可以直接使用此对象,如果不一致,则升级偏向锁为轻量级锁,通过自旋循环一定次数来获取锁,执行一定次数之后,如果还没有正常获取到要使用的对象,此时就会把锁从轻量级升级为重量级锁,此过程就构成了 synchronized 锁的升级。锁升级的目的是为了减低了锁带来的性能消耗。在 Java 6 之后优化 synchronized 的实现方式,使用了偏向锁升级为轻量级锁再升级到重量级锁的方式,从而减低了锁带来的性能消耗。

    可能你又会问什么是偏向锁?什么是轻量级锁?什么是重量级锁?这里就简单描述一下吧,能够帮你更好的理解synchronized。

    偏向锁(无锁):大多数情况下锁不仅不存在多线程竞争,而且总是由同一线程多次获得。偏向锁的目的是在某个线程获得锁之后(线程的id会记录在对象的Mark Word中),消除这个线程锁重入(CAS)的开销,看起来让这个线程得到了偏护。

    轻量级锁(CAS):就是由偏向锁升级来的,偏向锁运行在一个线程进入同步块的情况下,当第二个线程加入锁争用的时候,偏向锁就会升级为轻量级锁;轻量级锁的意图是在没有多线程竞争的情况下,通过CAS操作尝试将MarkWord更新为指向LockRecord的指针,减少了使用重量级锁的系统互斥量产生的性能消耗。

    重量级锁:虚拟机使用CAS操作尝试将MarkWord更新为指向LockRecord的指针,如果更新成功表示线程就拥有该对象的锁;如果失败,会检查MarkWord是否指向当前线程的栈帧,如果是,表示当前线程已经拥有这个锁;如果不是,说明这个锁被其他线程抢占,此时膨胀为重量级锁。

  • 方法二:使用Lock接口下的实现类。Lock是juc(java.util.concurrent)包下面的一个接口。常用的实现类就是ReentrantLock 类,它其实也是一种悲观锁。一种表现为 API 层面的互斥锁。通过lock() 和 unlock() 方法配合使用。因此也可以说是一种手动锁,使用比较灵活。但是使用这个锁时一定要注意要释放锁,不然就会造成死锁。一般配合try/finally 语句块来完成。比如:

    public class TicketThreadSafe extends Thread{
        private static int num = 5000;
        ReentrantLock lock = new ReentrantLock();
        @Override
        public void run() {
          while(num>0){
               try {
                 lock.lock();
                 if(num>0){
                   System.out.println(Thread.currentThread().getName()+"你的票号是"+num--);
                 }
                } catch (Exception e) {
                   e.printStackTrace();
                }finally {
                   lock.unlock();
                }
              }
        }
    }
    

    相比 synchronized,ReentrantLock 增加了一些高级功能,主要有以下 3 项:等待可中断、可实现公平锁,以及锁可以绑定多个条件。

    等待可中断是指:当持有锁的线程长期不释放锁的时候,正在等待的线程可以选择放弃等待,改为处理其他事情,可中断特性对处理执行时间非常长的同步块很有帮助。

    公平锁是指:多个线程在等待同一个锁时,必须按照申请锁的时间顺序来依次获得锁;而非公平锁则不保证这一点,在锁被释放时,任何一个等待锁的线程都有机会获得锁。synchronized 中的锁是非公平的,ReentrantLock 默认情况下也是非公平的,但可以通过带布尔值的构造函数要求使用公平锁。

    public ReentrantLock(boolean fair) {
          sync = fair ? new FairSync() : new NonfairSync();
      }

    锁绑定多个条件是指:一个 ReentrantLock 对象可以同时绑定多个 Condition 对象,而在 synchronized 中,锁对象的 wait() 和 notify() 或 notifyAll() 方法可以实现一个隐含的条件,如果要和多于一个的条件关联的时候,就不得不额外地添加一个锁,而 ReentrantLock 则无须这样做,只需要多次调用 newCondition() 方法即可。

    final ConditionObject newCondition() { //ConditionObject是Condition的实现类
              return new ConditionObject();
      } 
    
  • 方法三:使用线程本地存储ThreadLocal。当多个线程操作同一个变量且互不干扰的场景下,可以使用ThreadLocal来解决。它会在每个线程中对该变量创建一个副本,即每个线程内部都会有一个该变量,且在线程内部任何地方都可以使用,线程之间互不影响,这样一来就不存在线程安全问题,也不会严重影响程序执行性能。在很多情况下,ThreadLocal比直接使用synchronized同步机制解决线程安全问题更简单,更方便,且结果程序拥有更高的并发性。通过set(T value)方法给线程的局部变量设置值;get()获取线程局部变量中的值。当给线程绑定一个 Object 内容后,只要线程不变,就可以随时取出;改变线程,就无法取出内容.。这里提供一个用法示例:

    public class ThreadLocalTest {
        private static int a = 500;
        public static void main(String[] args) {
              new Thread(()->{
                    ThreadLocal local = new ThreadLocal();
                    while(true){
                          local.set(++a);   //子线程对a的操作不会影响主线程中的a
                          try {
                                Thread.sleep(1000);
                          } catch (InterruptedException e) {
                                e.printStackTrace();
                          }
                          System.out.println("子线程:"+local.get());
                    }
              }).start();
              a = 22;
              ThreadLocal local = new ThreadLocal();
              local.set(a);
              while(true){
                    try {
                          Thread.sleep(1000);
                    } catch (InterruptedException e) {
                          e.printStackTrace();
                    }
                    System.out.println("主线程:"+local.get());
              }
        }
    } 
    

    ThreadLocal线程容器保存变量时,底层其实是通过ThreadLocalMap来实现的。它是以当前ThreadLocal变量为key ,要存的变量为value。获取的时候就是以当前ThreadLocal变量去找到对应的key,然后获取到对应的值。源码参考如下:

    public void set(T value) {
          Thread t = Thread.currentThread();
          ThreadLocalMap map = getMap(t);
          if (map != null)
              map.set(this, value);
          else
              createMap(t, value);
      }
       ThreadLocalMap getMap(Thread t) {
          return t.threadLocals; //ThreadLocal.ThreadLocalMap threadLocals = null;Thread类中声明的
      }
      void createMap(Thread t, T firstValue) {
          t.threadLocals = new ThreadLocalMap(this, firstValue);
      }

    观察源码就会发现,其实每个线程Thread内部有一个ThreadLocal.ThreadLocalMap类型的成员变量threadLocals,这个threadLocals就是用来存储实际的变量副本的,键值为当前ThreadLocal变量,value为变量副本(即T类型的变量)。

    初始时,在Thread里面,threadLocals为空,当通过ThreadLocal变量调用get()方法或者set()方法,就会对Thread类中的threadLocals进行初始化,并且以当前ThreadLocal变量为键值,以ThreadLocal要保存的副本变量为value,存到threadLocals。

    然后在当前线程里面,如果要使用副本变量,就可以通过get方法在threadLocals里面查找即可。

  • 方法四:使用乐观锁机制。前面已经讲述了什么是乐观锁。这里就来描述在java开发中怎么使用的。

    其实在表设计的时候,我们通常就需要往表里加一个version字段。每次查询时,查出带有version的数据记录,更新数据时,判断数据库里对应id的记录的version是否和查出的version相同。若相同,则更新数据并把版本号+1;若不同,则说明,该数据发生了并发,被别的线程使用了,进行递归操作,再次执行递归方法,直到成功更新数据为止。

进程间通信

低级通信方式

pv信号量(当然也可以线程间使用)

高级通信方式,传输大量数据

三大类。

共享存储,分类两种,基于数据结构的共享,基于存储区的共享,操作系统只提供同步互斥工具(如信号量的PV操作),这块空间需要通过系统调用获取,不能同时读写。信号量其实也算是这种。

key_t ftok( char * fname, int id )

fname就时你指定的文件名(该文件必须是存在而且可以访问的),id是子序号, 虽然为int,但是只有8个比特被使用(0-255)。

在一般的UNIX实现中,是将文件的索引节点号取出,前面加上子序号得到key_t的返回值。如指定文件的索引节点号为65538,换算成16进制为 0x010002,而你指定的ID值为38,换算成16进制为0x26,则最后的key_t返回值为0x26010002。
查询文件inode的方法是: ls -i或者stat

vm_area_struct

消息传递,分为两种,直接通信与间接通信,直接通信直接把消息发给接受进程的缓冲队列上,信号和这个比较像,一般是单机中使用,间接通信则发给某个中间实体,接受进程从中间实体取得消息,这个实体一般叫做信箱,间接通信用于分布式系统中,比方说现在分布式系统中,常用的消息队列rocketmq,通过套接字来传输。

管道通信,分为两种,匿名管道和命名管道,通过内存中一个共享文件(因为是内存中,我更喜欢叫他缓冲区)实现的,可以说是共享存储+互斥工具的封装,默认是4KB(ulimite -a命令可以查看),缓冲区允许一边写入,另一边读出,满时会write自动阻塞,空时read自动阻塞,且是一次性读取,读完自动释放空间,匿名管道只能单向,命名管道可以双向

PID0 PID1

pid0是调度进程,内核态

pid1是init进程 用户进程,非内核态运行,root 权限。 有多种,早期是systemV,现在是systemd,初始化

(延伸,第一个进程如何启动)

(延伸,12个权限位)进程可以通过getpid函数,获得他的pid

effective user id ,set位置1,则为程序文件的owner。文件权限不止9个,12个

real user id

进程的创建

fork 两个返回值,<0异常,=0子进程,>0父进程,子进程是父进程的copy,堆栈一样,缓冲区也会复制,文件共享,(打开的fd也会共享,并且偏移量一样,本质是多个进程fd指向系统fd表中的同一个fd)。不共享内存,但共享text segment。

不能保证哪个进程先执行。

子进程pid一般大于父进程pid,因为pid会延迟复用

> 输出重定向 fd1标准输出重定向输出到文件
< 输入重定向 fd0标准输入重定向从文件中读

write也是可以写到标准输出中的。

fd0标准输入,fd1标准输出,fd2标准错误

左值,++i 是一个表达式,求值后,表达式的值在内存中可以找到 可以这样赋值 ++i=5

右值,i++,额外的内存分配与回收?额外分配了个i吗

++a比a++快?

umask

cow(copy on write)

exec

进程怎么终止

8种方法

进程终止后,pid reuse 有delay

函数本质是地址,指针

正常终止之Exit Functions,c程序是如何启动和结束的?

_exit _Exit 立刻返回kernel

exit 做一些清理操作。如fclose,fflush也可以自己注册一些函数。

atexit stdlib.h

原型 int atexit(void (*func)(void));

atexit()用来注册一个函数,程序退出前会调用,可以多次注册同一个函数

用栈记录,先注册后执行

退出有参数,exit status ,可以是未定义的,如,三个退出函数没有给status,主函数没有返回值,主函数没有声明返回值为整数echo $? 表示shell执行的上一条命令的退出状态值

(延伸,延迟写,0拷贝)但调用flush不等于说数据就一定立刻会写往硬盘。

man close

A successful close does not guarantee that the data has been successfully saved to disk, as the kernel defers writes.It is not common for a filesystem to flush the buffers when the stream is closed. If you need to be sure that the data is physically stored use fsync(2). (It will depend on the disk hardware at this point.)

1230546-20200827114641367-1713900664

比如说从主机磁盘上读取数据再发送到网络。

磁盘,内核缓冲区,io缓冲区,应用程序指定的位置,网卡缓冲区

而且数据拷贝需要由CPU来调控,所以如果反复这样,将大大增加系统的开销!

(延伸,如何解决)

不用io缓冲区,直接到应用程序指定的位置

更好则是直接内核间拷贝

mmap()系统调用

mmap内存映射就可以绕过标准I/O缓冲区,直接将内核数据直接拷贝到指定的映射区,而mmap内存映射区可以在应用程序和操作系统之间共享,也可以在进程之间共享!

sendfile()系统调用

该函数用于从一个文件拷贝数据到另一个文件中,它执行的拷贝操作完全在内核中完成,避免了向用户空间进行不必要的拷贝。所以该函数适合对文件不加修改的I/O操作。

比如说上面说的应用(发送数据到网络上),那么可以使用sendfile将数据从内核缓冲区直接拷贝到内核空间socket缓冲区中!

其他方式可以直接使用read和write的系统调用而不是标准I/O的fread和fwrite这一系列函数;亦可以使用直接I/O(O_DIRECT)。

POSIX

操作系统系统调用,库函数。POSIX:可移植操作系统接口(Portable Operating System Interface of UNIX,缩写为 POSIX 。POSIX兼容也就指库函数的接口函数兼容,但是并不管API具体如何实现

如unistd 一般为类UNIX系统使用,内部包含很多POSIX标准系统调用,如fork();

stdlib 为标准C函数库,如malloc();

stdio 为标准输入输出函数库,如printf()

进程终止

如果父进程比子进程先死掉,init进程会接管子进程。进程终止时,内核会遍历所有active状态的进程,如果发现某一个进程的父进程是终止的进程,则会把此进程的ppid设置为1号进程,从而保证每一个用户进程有一个父进程

进程线程调度

先来先服务调度算法

最短作业优先调度算法

高响应比优先调度算法

img

时间片轮转调度算法

通常时间片设为 20ms~50ms 通常是一个比较合理的折中值。

最高优先级调度算法

静态优先级:创建进程时候,就已经确定了优先级了,然后整个运行时间优先级都不会变化;

动态优先级:根据进程的动态变化调整优先级,比如如果进程运行时间增加,则降低其优先级,如果进程等待时间(就绪队列的等待时间)增加,则升高其优先级,也就是随着时间的推移增加等待进程的优先级

多级反馈队列调度算法

是「时间片轮转算法」和「最高优先级算法」的综合和发展。兼顾了长短作业,同时有较好的响应时间。

「多级」表示有多个队列,每个队列优先级从高到低,同时优先级越高时间片越短。

「反馈」表示如果有新的进程加入优先级高的队列时,立刻停止当前正在运行的进程,转而去运行优先级高的队列;

多级反馈队列

时间片分配

内核分配时间片是有策略和倾向性的。换句话说,内核是偏心的,它喜欢的是IO消耗型进程,因为这类进程如果不能及时响应,用户就会很不爽,所以它总会下意识的多分配CPU运行时间给这类进程。而CPU消耗进程内核就不太关心了。这有道理吗?太有了,CPU消耗型慢一点用户感知不出来,电信号和生物信号运转速度差距巨大。虽然内核尽量多的分配时间片给IO消耗型进程,但IO消耗进程常常在睡觉,给它的时间片根本用不掉。很合理吧?

那么内核具体是怎么实现这种偏心呢?通过动态调整进程的优先级,以及分配不同长短的CPU时间处来实现。先说内核如何决定时间片的长度。

对每一个进程,有一个整型static_prio表示用户设置的静态优先级,内核里它与nice值是对应的。看看进程描述结构里的static_prio成员。

struct task_struct {  
    int prio, static_prio;  
......}  

复制

nice值是什么?其实就是优先级针对用户进程的另一种表示法,nice的取值范围是-20到+19,-20优先级最高,+19最低。上篇曾经说过,内核优先级共有140,而用户能够设置的NICE优先级如何与这140个优先级对应起来呢?看代码:

#define MAX_USER_RT_PRIO    100  
#define MAX_RT_PRIO     MAX_USER_RT_PRIO  
#define MAX_PRIO        (MAX_RT_PRIO + 40)  

复制

可以看到,MAX_PRIO就是140,也就是内核定义的最大优先级了。

#define USER_PRIO(p)        ((p)-MAX_RT_PRIO)  
#define MAX_USER_PRIO       (USER_PRIO(MAX_PRIO))  

复制

而MAX_USER_PRIO就是40,意指,普通进程指定的优先级别最多40,就像前面我们讲的那样-20到+19。

#define NICE_TO_PRIO(nice)  (MAX_RT_PRIO + (nice) + 20)  

复制

nice值是-20表示最高,对应着static_prio是多少呢?NICE_TO_PRIO(0)就是120,NICE_TO_PRIO(-20)就是100。

当该进程刚被其父进程fork出来时,是平分其父进程的剩余时间片的。这个时间片执行完后,就会根据它的初始优先级来重新分配时间片,优先级为+19时最低,只分配最小时间片5ms,优先级为0时是100ms,优先级是-20时是最大时间片800ms。我们看看内核是如何计算时间片长度的,大家先看下task_timeslice时间片计算函数:

#define SCALE_PRIO(x, prio) \  
    max(x * (MAX_PRIO - prio) / (MAX_USER_PRIO/2), MIN_TIMESLICE)  

static unsigned int task_timeslice(task_t *p)  
{  
    if (p->static_prio < NICE_TO_PRIO(0))  
        return SCALE_PRIO(DEF_TIMESLICE*4, p->static_prio);  
    else  
        return SCALE_PRIO(DEF_TIMESLICE, p->static_prio);  
}  

复制

这里有一堆宏,我们把宏依次列出看看它们的值:

# define HZ     1000      
#define DEF_TIMESLICE       (100 * HZ / 1000)  

复制

所以,DEF_TIMESLICE是100。假设nice值是-20,那么static_prio就是100,那么SCALE_PRIO(100*4, 100)就等于800,意味着最高优先级-20情形下,可以分到时间片是800ms,如果nice值是+19,则只能分到最小时间片5ms,nice值是默认的0则能分到100ms。

貌似时间片只与nice值有关系。实际上,内核会对初始的nice值有一个-5到+5的动态调整。这个动态调整的依据是什么呢?很简单,如果CPU用得多的进程,就把nice值调高点,等价于优先级调低点。CPU用得少的进程,认为它是交互性为主的进程,则会把nice值调低点,也就是优先级调高点。这样的好处很明显,因为1、一个进程的初始优先值的设定未必是准确的,内核根据该进程的实时表现来调整它的执行情况。2、进程的表现不是始终如一的,比如一开始只是监听80端口,此时进程大部分时间在sleep,时间片用得少,于是nice值动态降低来提高优先级。这时有client接入80端口后,进程开始大量使用CPU,这以后nice值会动态增加来降低优先级。

思想明白了,代码实现上,优先级的动态补偿到底依据什么呢?effective_prio返回动态补偿后的优先级,注释非常详细,大家先看下。

/* 
 * effective_prio - return the priority that is based on the static 
 * priority but is modified by bonuses/penalties. 
 * 
 * We scale the actual sleep average [0 .... MAX_SLEEP_AVG] 
 * into the -5 ... 0 ... +5 bonus/penalty range. 
 * 
 * We use 25% of the full 0...39 priority range so that: 
 * 
 * 1) nice +19 interactive tasks do not preempt nice 0 CPU hogs. 
 * 2) nice -20 CPU hogs do not get preempted by nice 0 tasks. 
 * 
 * Both properties are important to certain workloads. 
 */  
static int effective_prio(task_t *p)  
{  
    int bonus, prio;  

    if (rt_task(p))  
        return p->prio;  

    bonus = CURRENT_BONUS(p) - MAX_BONUS / 2;  

    prio = p->static_prio - bonus;  
    if (prio < MAX_RT_PRIO)  
        prio = MAX_RT_PRIO;  
    if (prio > MAX_PRIO-1)  
        prio = MAX_PRIO-1;  
    return prio;  
}  

复制

可以看到bonus会对初始优先级做补偿。怎么计算出这个BONUS的呢?

#define CURRENT_BONUS(p) \  
    (NS_TO_JIFFIES((p)->sleep_avg) * MAX_BONUS / \  
        MAX_SLEEP_AVG)  

复制

可以看到,进程描述符里还有个sleep_avg,动态补偿完全是根据它的值来运作的。sleep_avg就是关键了,它表示进程睡眠和运行的时间,当进程由休眠转到运行时,sleep_avg会加上这次休眠用的时间。在运行时,每运行一个时钟节拍sleep_avg就递减直到0为止。所以,sleep_avg越大,那么就会给到越大的动态优先级补偿,达到MAX_SLEEP_AVG时会有nice值-5的补偿。

内核就是这么偏爱交互型进程,从上面的优先级和时间片分配上都能看出来。实际上,内核还有方法对交互型进程搞优待。上篇说过,runqueue里的active和expired队列,一般的进程时间片用完后进expired队列,而对IO消耗的交互型进程来说,则会直接进入active队列中,保证高灵敏的响应,可见什么叫万千宠爱于一身了。

内核如何调度不同进程的多个线程?

时间片实际上是分给进程的,分给进程后再去调度线程。

用户线程与内核线程如果是一一对应,那么究竟有几个线程?

一个

内存管理

背景-unix大家族

img

可以看得出来,早期有两大块,SystemV(目前一般采用其第4个版本SVR4)和BSD两种,这也是两种主要风格。其代表操作系统本别是Solaris和FreeBSD

SystemV有运行级别,分为7个级别,BSD没有运行级别的概念。

运行级别0:系统停机状态,系统默认运行级别不能设为0,否则一开机就会重启
运行级别1:单用户工作状态,root权限,用于系统维护,禁止远程登陆
运行级别2:多用户状态(没有NFS)
运行级别3:完全的多用户状态(有NFS),登陆后进入控制台命令行模式
运行级别4:系统未使用,保留
运行级别5:X11控制台,登陆后进入图形GUI模式
运行级别6:系统正常关闭并重启,默认运行级别不能设为6,否则一开机就会重启

Linux是后出现的,因此借鉴了SystemV和BSD,我个人觉得比较偏向systemV。默认shell是bash

早期的init1号进程用的是systemV,现在是systemd。

linux和systemv一样有7个启动级别,不同级别的脚本存放在/etc/rc0.d rc1.d rc2.d,里面都是软连接,实际脚本存在/etc/init.d。多数的桌面的linux系统缺省的runlevel是5,我自己的服务器就是5。runlevel命令可以看。

这里写图片描述

/etc/rc0.d下命名规则 S/K + 两位数字 + 服务名

S开头的文件,系统将启动对应的服务,K则不启动。两位数字表示启动顺序。

posix 就是为了解决在这么多类unix系统之间,源代码级别的可移植性。提供统一的函数调用形式。

共享内存(System V, POSIX)三种实现原理

System V (shmget/shmat/shmdt)

  • 原始的共享内存机制,仍然广泛使用

  • 在不相关进程之间的共享

    操作系统管理的tmpfs

POSIX (shm_open/shm_unlink)

  • 在不相关进程之间的共享, 没有文件系统 I/O 开销
  • 旨在比旧的 API 更简单、更好

shm_open其实就是在用户可见的/dev/shm目录(tmpfs)下新建一个文件。

mmap

什么叫内存映射?把虚拟内存区域和一个磁盘文件对象对应起来,一般用于不相关进程间共享

实际上mmap有多种模式,也可以不和具体文件关联,而是匿名映射,但这种只能是在相关进程之间共享(通过 fork ()相关),用的是操作系统管理的tmpfs,实际上malloc申请大块内存空间就是调用mmap的匿名映射。

不匿名映射,则可以指定一个正常的文件。

查询文档man7.org

原理-tmpfs

tmpfs是一种虚拟文件系统,直接在RAM上创建文件。

sudo mount -t tmpfs -o size=10M tmpfs /mnt/mytmpfs # 创建一个属于tmpfs的文件并挂载

该文件系统存在以下几个属性:

  • 当物理内存不足时,tmpfs可以使用swap的空间
  • tmpfs只会使用一定的物理空间和swap空间
  • 如果重新挂载,可以改变tmpfs的大小

如果tmpfs文件系统被卸载,则上面存储的内容也会丢失。

systemV的shmget共享内存和mmap的匿名内存映射实现用的都是它,由操作系统内核管理,用户不可见。

挂载在 /dev/shm 的tmpfs文件系统用于实现 POSIX 共享内存和信号量。默认大小为物理内存的 1/2。用户可见

通过 /proc/meminfo文件的Shmem字段以及通过 free 命令显示的 shared 列可以查看消耗的tmpfs的容量

虚拟地址到物理地址转化

虚拟内存的流程

页面不在物理内存的时候会发生缺页中断,缺页中断调入页面时,物理内存可能满了,那么就需要置换页面到磁盘。

页面置换算法

OPT最佳算法,

淘汰最长时间不被访问(极限情况下永久不再用)的页面,无法实现,但可以用来评价其他算法,

FIFO先进先出,

只有他可能出现Belady(贝拉迪)异常,增加物理块,缺页次数反而增加

LRU Least Recently Used

最近最少用的,也叫最近最久未使用,开销较大。理论上可以证明堆栈类算法不可能出现Belady(贝拉迪)异常。

实现:一个栈,栈底是最早用过的,栈顶是最近刚用过的,当要使用新的页面时,需要先遍历这个栈找栈中是否存在这个页面,如果找到则删除并移到栈顶。遍历开销比较大。一般用链表实现这个栈。

进阶实现:基于 HashMap 和 双向链表实现 LRU

img

redis中lru的实现,采用了一个近似的做法,就是随机取出若干个key,默认5个,然后按照访问时间排序后,淘汰掉最不经常使用的。volatile-lru 只淘汰设置了过期时间的, allkeys-lru则没限制。volatile-ttl淘汰设置了过期时间的,并且剩余生存时间少的。

根据Redis作者的说法,每个Redis Object可以挤出24 bits的空间,但24 bits是不够存储两个指针的,而存储一个低位时间戳是足够的,Redis Object以秒为单位存储了对象新建或者更新时的unix time,也就是LRU clock,24 bits数据要溢出的话需要194天,而缓存的数据更新非常频繁,已经足够了。

一般的经验规则:

  • 使用allkeys-lru策略:当预期请求符合一个幂次分布(二八法则等),比如一部分的子集元素比其它其它元素被访问的更多时,可以选择这个策略。
  • 使用allkeys-random:循环连续的访问所有的键时,或者预期请求分布平均(所有元素被访问的概率都差不多)
  • 使用volatile-ttl:要采取这个策略,缓存对象的TTL值最好有差异
#define REDIS_LRU_CLOCK_MAX ((1<<REDIS_LRU_BITS)-1) /* 24个1 */
#define REDIS_LRU_CLOCK_RESOLUTION 1 /* LRU clock 分辨率,单位s,调大,可以表示的时间范围变大,精度降低 *
//更新key的时间 unixtime单位是1970以来的秒,然后求余到redis的REDIS_LRU_CLOCK_MAX范围内,大概是194天。
void updateLRUClock(void) {
    server.lruclock = (server.unixtime / REDIS_LRU_CLOCK_RESOLUTION) &
                                                REDIS_LRU_CLOCK_MAX;
}
//估计时间
unsigned long estimateObjectIdleTime(robj *o) {
    //对象时间小于当前时间
    if (server.lruclock >= o->lru) {
        return (server.lruclock - o->lru) * REDIS_LRU_CLOCK_RESOLUTION;
    } else {//对象时间大于当前时间,说明是上个周期的。需要调整为。max-objtime + 当前时间。
        return ((REDIS_LRU_CLOCK_MAX - o->lru) + server.lruclock) *
                    REDIS_LRU_CLOCK_RESOLUTION;
    }
}
            /* volatile-lru and allkeys-lru policy */
            else if (server.maxmemory_policy == REDIS_MAXMEMORY_ALLKEYS_LRU ||
                server.maxmemory_policy == REDIS_MAXMEMORY_VOLATILE_LRU)
            {
                //for循环采样次,每次获取时间,找出最久的那个
                for (k = 0; k < server.maxmemory_samples; k++) {
                    sds thiskey;
                    long thisval;
                    robj *o;

                    de = dictGetRandomKey(dict);
                    thiskey = dictGetKey(de);
                    /* When policy is volatile-lru we need an additional lookup
                     * to locate the real key, as dict is set to db->expires. */
                    if (server.maxmemory_policy == REDIS_MAXMEMORY_VOLATILE_LRU)
                        de = dictFind(db->dict, thiskey);
                    o = dictGetVal(de);
                    thisval = estimateObjectIdleTime(o);

                    /* 记录最长的 */
                    if (bestkey == NULL || thisval > bestval) {
                        bestkey = thiskey;
                        bestval = thisval;
                    }
                }
            }

CLOCK时钟

用较小的开销逼近LRU。简单的CLOCK算法,NearRU。每个页面串起来一个循环队列,增加一个访问位,访问时置为1.并且有个替换指针,挨个遍历,碰到0则替换,碰到1则置为0

改进型CLOCK置换算法

如果一个页面修改过,则替换前要先写回磁盘,显然修改过的页面替换的代价更大。因此增加一个修改位。算法性能下降,但是可以减少io次数。

首先是扫描寻找既未访问也未修改过的页面,注意最多扫描一整轮。

如果没找到,则找未被访问,但已经被修改的的。并且扫过的访问位都置为0

如果一轮过后仍未找到,则所有帧的访问位置为0,重复上述步骤

LFU最不常用置换算法

对每个页面设置一个「访问计数器」,每当一个页面被访问时,该页面的访问计数器就累加 1。在发生缺页中断时,淘汰计数器值最小的那个页面。

内存分配

分配在堆上

malloc 初始化不确定

calloc 初始化为0

realloc 再分配,增加或减少,增加时,初始化不确定。

malloc申请内存

malloc() 源码里默认定义了一个阈值:

  • 如果用户分配的内存小于 128 KB,则通过 brk() 申请内存;

  • 如果用户分配的内存大于 128 KB,则通过 mmap() 申请内存;

  • malloc 通过 brk() 方式申请的内存,free 释放内存的时候,并不会把内存归还给操作系统,而是缓存在 malloc 的内存池中,待下次使用

  • malloc 通过 mmap() 方式申请的内存,free 释放内存的时候,会把内存归还给操作系统,内存得到真正的释放

Cache与buffer

相同点:介于高速设备和低速设备之间
不同点:
存放的数据
Cache存放的是低速设备上的某些数据的Copy,高速缓存上有的数据低速设备上必定有。
Buffer存放的是低速设备和高速设备之间传递的数据,而这些数据在另一端不一定有备份,经由Buffer传到另一端。
是否允许直接访问低速设备
Cache存放的是高速设备经常要访问的数据,如果高速设备要访问的数据不在高速缓存中,高速设备只能直接访问低速设备。
Buffer是高速设备和低速设备传递数据的必经之处,高速设备不会直接访问低速设备。

自由跳转

goto 函数内

longjump,可以不同函数

#include<setjump.h>

jmp_buf jmpbuffer;

setjump(jmpbuffer);//直接调用返回0,跳转过来时调用的返回值则为longjump中给定的
longjump(jmpbuffer,1);//会跳到setjump函数

VSZ,RSS,VSZ

linux中使用ps命令可以看到vsz,rss。单位是KB

RSS是Resident Set Size(常驻内存大小)的缩写,用于表示进程使用了多少内存(RAM中的物理内存),RSS不包含已经被换出的内存。RSS包含了它所链接的动态库并且被加载到物理内存中的内存。RSS还包含栈内存和堆内存。

VSZ是Virtual Memory Size(虚拟内存大小)的缩写。它包含了进程所能访问的所有内存,包含了被换出的内存,被分配但是还没有被使用的内存,以及动态库中的内存。

假设进程A的二进制文件是500K,并且链接了一个2500K的动态库,堆和栈共使用了200K,其中100K在内存中(剩下的被换出或者不再被使用),一共加载了动态库中的1000K内容以及二进制文件中的400K内容至内存中,那么:

RSS: 400K + 1000K + 100K = 1500K
VSZ: 500K + 2500K + 200K = 3200K
由于部分内存是共享的,被多个进程使用,所以如果将所有进程的RSS值加起来可能会大于系统的内存总量。

申请过的内存如果程序没有实际使用,则不显示在RSS里。比如说一个程序,预先申请了一大批内存,过了一段时间才使用,你会发现RSS会增长而VSZ保持不变。

还有一个概念是PSS,它是proportional set size(proportional是成比例的意思)的缩写。这是一种新的度量方式。它将动态库所使用的内存按比例划分。比如我们前面例子中的动态库如果是被两个进程使用,那么:

PSS: 400K + (1000K/2) + 100K = 400K + 500K + 100K = 1000K
一个进程中的多个线程共享同样的地址空间。所以一个进程中的多个线程的RSS,VSZ,PSS是完全相同的。linux下可以使用ps或者top命令查看这些信息。

在 4GB 物理内存的机器上,申请 8G 内存会怎么样?

  • 在 32 位操作系统,因为进程最大只能申请 3 GB 大小的虚拟内存,所以直接申请 8G 内存,会申请失败。
  • 在 64 位操作系统,因为进程最大只能申请 128 TB 大小的虚拟内存,即使物理内存只有 4GB,申请 8G 内存也是没问题,因为申请的内存是虚拟内存,等这块虚拟内存被访问了,因为物理空间不够,就会发生 OOM。

(延伸)实验过程:

malloc

memset

0x%x 输出地址

ps aux | grep 程序名 查看vsz和rss

(延伸,原理)

应用程序通过 malloc 函数申请内存的时候,实际上申请的是虚拟内存,此时并不会分配物理内存。

当应用程序读写了这块虚拟内存,CPU 就会去访问这个虚拟内存,这时会发现这个虚拟内存没有映射到物理内存,CPU 就会产生缺页中断,进程会从用户态切换到内核态,并将缺页中断交给内核的 Page Fault Handler (缺页中断函数)处理。

缺页中断处理函数会看是否有空闲的物理内存:

  • 如果有,就直接分配物理内存,并建立虚拟内存与物理内存之间的映射关系。
  • 如果没有空闲的物理内存,那么内核就会开始进行回收内存的工作,如果回收内存工作结束后,空闲的物理内存仍然无法满足此次物理内存的申请,那么内核就会放最后的大招触发 OOM (Out of Memory)机制。

文件系统

磁盘调度

磁盘调度是什么

磁盘是由多个圆的盘片组成,盘片上的不同半径的圆就是磁道,一个磁道可以划分多个扇区。圆的中心有个机械臂,机械臂的磁头指向不同的磁道。我们说的调度算法就是指这个磁头指向的不同磁道。

先来先服务算法

最短寻道时间优先算法

优先选择距离当前磁头位置最近的请求。有可能出现饥饿现象,如有一个距离特别远的,如果后续来的请求都比他近,那么就一直不会访问那个特别远的。

扫描算法算法SCAN

磁头在一个方向上移动,访问所有未完成的请求,直到磁头到达该方向上的最后的磁道,才调换方向

但是中间部分的磁道会比较占便宜,中间部分相比其他部分响应的频率会比较多

循环扫描算法C-SCAN

只有磁头朝某个特定方向移动时,才处理磁道访问请求,而返回时直接快速移动至最靠边缘的磁道,也就是复位磁头,这个过程是很快的,并且返回中途不处理任何请求,该算法的特点,就是磁道只响应一个方向上的请求

LOOK 与 C-LOOK 算法

SCAN的优化:LOOK 磁头在移动到「最远的请求」位置,然后立即反向移动

C-SCAN 的优化:CLOOK 反向移动的途中不会响应请求

虚拟存储器

虚拟存储器的最大容量是由(ac)决定的。

  • 计算机系统的地址结构和外存空间
  • 页表长度
  • 内存空间
  • 逻辑空间

虚拟存储器的最大容量 = min(内存+外存,2^n)。n为计算机的地址总线位数。

Linux虚拟文件系统四大对象

1)超级块(super block)

超级块对象表示一个文件系统。它存储一个已安装的文件系统的控制信息,包括文件系统名称(比如Ext2)、文件系统的大小和状态、块设备的引用和元数据信息(比如空闲列表等等)。VFS超级块存在于内存中,它在文件系统启动时建立,并且在文件系统卸载时自动删除。同时需要注意的是对于每个具体的文件系统来说,也有各自的超级块,它们存放于磁盘。

2)索引节点(inode)

索引结点对象存储了文件的相关元数据信息,例如:文件大小、设备标识符、用户标识符、用户组标识符等等。Inode分为两种:一种是VFS的Inode,一种是具体文件系统的Inode。前者在内存中,后者在磁盘中。所以每次其实是将磁盘中的Inode调进填充内存中的Inode,这样才是算使用了磁盘文件Inode。当创建一个文件的时候,就给文件分配了一个Inode。一个Inode只对应一个实际文件,一个文件也会只有一个Inode

3)目录项(dentry)

引入目录项对象的概念主要是出于方便查找文件的目的。不同于前面的两个对象,目录项对象没有对应的磁盘数据结构,只存在于内存中。一个路径的各个组成部分,不管是目录还是普通的文件,都是一个目录项对象。如,在路径/home/source/test.java中,目录 /, home, source和文件 test.java都对应一个目录项对象。VFS在查找的时候,根据一层一层的目录项找到对应的每个目录项的Inode,那么沿着目录项进行操作就可以找到最终的文件。

4)文件对象(file)

文件对象描述的是进程已经打开的文件。因为一个文件可以被多个进程打开,所以一个文件可以存在多个文件对象。一个文件对应的文件对象可能不是唯一的,但是其对应的索引节点和目录项对象肯定是惟一的。

Shell

我们用下面说的shell用bash举例,现在linux系统默认shell为bash。

bash与sh区别:是两种shell,以前sh会直接链接到bash(bash 支持一个 –posix 参数,这使得它更符合POSIX标准。如果以sh 的形式调用,它试图模仿POSIX),但现代的系统则不一定,如ubuntu20.04链接到dash。sh语法遵循posix标准,bash则不完全兼容。

image-20220603222311438

img

启动类型

  • 交互式登录
    一个个地输入命令并及时查看它们的输出结果,整个过程都在跟 Shell 不停地互动。
  • 交互式非登录
    运行一个 Shell 脚本 文件,让所有命令批量化、一次性地执行。
  • 非交互式登录
    需要输入用户名和密码才能使用。
  • 非交互式非登录
    直接可以使用。

辨别是否为交互式?

法一:输出PS1变量,如果返回任何内容代表是interactive shell,为空则为no-interactive shell

法二:输出-变量,带 i 字符的就是interactive,没有带i字符就不是。

image-20220602212606913

辨别是否login?

shopt login_shell

shopt用于显示和设置shell中的行为选项

不同启动类型的配置文件加载区别

在开始讨论区别前,需要特别注意一个概念:bash在执行除了内建命令以外的其他命令时都会fork一个子bash进程,虽然可能没有加载配置文件,但是会继承父bash的环境变量,看起来像是加载了配置文件(但实际上子shell是没有加载配置文件的,是父shell加载的配置文件)。

交互式login

获得方法:使用 ssh 登录到一个系统时,我们得到一个交互login shell

配置文件加载

首先执行 全局 的/etc/profile文件,然后这个文件内部会加载/etc/bash.bashrc和*/etc/profile.d/.sh**

然后检查 局部 的.bash_profile是否存在于home/用户/目录中。如果是,那么 Bash 用source命令执行它(为了避免交互式login比non-login少执行,交互式的.bash_profile 会调用 .bashrc)。 如果找不到,才会去寻找 .bash_login和.profile并且只执行第一个可读文件。

总的来说,对于正常ssh的交互式login下面四个文件都会执行,其中/etc/bashrc文件在ubuntu20.04中叫/etc/bash.bashrc

image-20220604165050703

/etc/profile 文件有如下一段代码:

for i in /etc/profile.d/*.sh /etc/profile.d/sh.local ; do #遍历 /etc/profile.d 目录下所有以 .sh 结尾的文件和 sh.local 文件
    if [ -r "$i" ]; then #判断它们是否可读
        if [ "${-#*i}" != "$-" ]; then #如果可读,判断当前Shell启动方式是不是交互式 。${-#*i} 这个表达式的意思是:从左向右,在 - 变量中找到第一个 i ,并截取 i 之后的子串。
            . "$i" #source "i”的简写, Shell 的模块化方式
        else
            . "$i" >/dev/null #否则,也在当前 Shell 进程中执行该脚本,只不过将输出重定向到了 /dev/null中。
        fi
    fi
done

非交互式login

获得方法bash --login script.sh,–login参数是将shell作为一个login shell启动,而执行脚本又使它为non-interactive shell。

配置文件加载:配置文件的加载与第一种完全一样

交互式non-login

获得方法:当我们在一个已经登录的 shell 上调用一个新的 shell 时,如执行bash或者su命令切换用户,我们默认都会得到一个交互式的非登录 shell

配置文件加载:只执行/etc/bash.bashrc.bashrc文件,但是一般是从交互式shell中运行脚本进入非交互式的,所以会继承父交互式shell的环境变量。

image-20220603213013417

但都可以加–login参数来切换到登录式

image-20220603213103791

非交互式non-login

获得方法

  • 执行脚本时会创建新的shell,如bash script.sh
  • 运行头部有如#!/usr/bin/env bash的可执行文件,如./executable
  • ssh远程执行脚本或命令,如ssh user@remote script.sh 特别注意这种情况,因为上面两种虽然不加载配置文件,但是是直接从父shell中fork出来的子shell,因此会继承父shell的环境变量,而ssh不登录直接执行,是创建了新的shell,没有环境变量可以继承,只有bash内置的环境变量。有些自动化运维工具就是这种,因此编写脚本时需要特别注意环境变量问题。

配置文件加载:不执行任何启动文件。但是bash会去寻找环境变量BASH_ENV(注意不是所有的shell哦,这里只针对bash),将变量的值作为文件名进行查找,如果找到便加载它。

image-20220603220014246

image-20220603213910202

应用-编写脚本

!指定了解释器

第一行指定脚本解释器,建议为#!/usr/bin/env bash,并设置$BASH_ENV环境变量为/etc/profile(这个文件实际上内部加载了其他的文件,如bashrc等),这样脚本的环境变量就和login交互式一样了。

运维的一个坑

ansible(一个自动化运维工具)的shell模块远程执行命令应该就是属于non-login + non-interactive。对于这种模式,bash会选择加载$BASH_ENV的值对应的文件。但是,注意到命令里面的那个脚本的第一行#!/usr/bin/env sh,并不是bash,而是sh。那么bash和sh有啥区别呢?通过执行whereis命令查看发现,sh只是bash的一个软链接(较旧版本)。再通过查看文档知道,当bash以sh命令启动时,bash会尽可能的模仿sh,所以配置文件的加载变成了下面这样:

  • interactive + login: 读取/etc/profile和~/.profile
  • non-interactive + login: 同上
  • interactive + non-login: 读取ENV环境变量对应的文件
  • non-interactive + non-login: 不读取任何文件

所以如果是sh的话,不会加载任何环境变量,结果是command not found。

解决办法就是将#!/usr/bin/env sh改成#!/usr/bin/env bash,然后可以设置$BASH_ENV/etc/profile

总结

对于bash。

login,加载/etc/profile,进而加载/etc/bash.bashrc,加载~/.bash_profile进而加载~/.bashrc
交互,那就只加载~/.bash_profile进而加载~/.bashrc。
不登录不交互,那默认啥配置文件也不加载,只有bash内置的最基本的环境变量。但如果指定了BASH_ENV环境变量,则会加载这个变量指定的文件

常用命令

建立软连接方式

ln -s 目标文件完整路径 源文件完整路径 //-s表明是软连接,也就是windows下快捷方式一样的。

文件目录

pwd

显示当前所在目录的位置

image-20220425202118832.png

ls

ls 显示当前目录下的所有文件名(不含隐藏文件)

image-20220425164957360.png

ls -a显示所有文件名(含隐藏文件)

ls -l显示当前目录下的所有文件详细信息

示意图

image-20220425165340697.png

ls -s显示占用的磁盘块数

image-20220425165613018.png

磁盘块是什么呢?

我们从机械硬盘讲起,首先了解扇区的概念:

绿色部分为扇区

一圈灰色的部分叫做磁道

image-20220425170559136.png

存储容量 = 磁头数 × 磁道(柱面)数 × 每道扇区数 × 每扇区字节数

下图磁盘是一个 3个圆盘6个磁头,7个柱面(每个盘片7个磁道) 的磁盘,图3中每条磁道有12个扇区,所以此磁盘的容量为: 6 7 12 * 512 = 258048字节

image-20220425170454096.png

硬盘的最小存储单位就是扇区了,而且硬盘本身并没有block的概念

文件系统不是一个扇区一个扇区的来读数据,太慢了,所以有了block(块)的概念,它是一个块一个块的读取的,block才是文件存取的最小单位。

df磁盘管理命令,先来知道是哪种文件系统

df -T 显示文件系统的形式

image-20220425171020727.png

df -h更可读,采用M,G描述大小

image-20220425202014629.png

可见是ext4文件系统

sudo tune2fs -l /dev/vdb | grep "Block size"

image-20220425171109005.png

一个block是4K字节,也就是说我所使用的文件系统中1个块是由连续的8个扇区组成。

扇区是对硬盘而言,块是对文件系统而言。

(延伸,df与du)

df 命令的全称是 Disk Free ,显而易见它是统计磁盘中空闲的空间,也即空闲的磁盘块数。 它是通过文件系统磁盘块分配图进行计算出的。原理:vfs四大对象之超级块

du 命令的全称是 Disk Used ,统计磁盘有已经使用的空间。 它是直接统计各文件各目录的大小,而不是从硬盘获得信息的。原理:vfs四大对象之inode,类似stat命令

tree

以树的形式显示目录,更加形象

image-20220425201054770.png

du

显示目录文件大小,单位kByte

-h 以M,G显示,更清楚,按照1024换算

image-20220425203847793.png

image-20220425205203044.png

cd

切换目录

cd 目录

image-20220425201737159.png

mkdir

创建目录

cp

拷贝文件,目录需要加-r

rm

删除文件,目录需要加-r

rmdir

等价于rm -r

mv

移动文件

find

用于在指定目录下查找文件

任何位于参数之前的字符串都将被视为欲查找的目录名。如果使用该命令时,不设置任何参数,则 find 命令将在当前目录下查找子目录与文件。并且将查找到的子目录和文件全部进行显示。

find / -name xxx全盘查找名称含是xxx的文件和目录,注意是全称匹配

此时已经了解了如何查看linux目录中有哪些文件,那么我们如何查看文件的具体内容呢?

文本查看

cat

image-20220425172002125.png

more

more 命令类似 cat ,不过会以一页一页的形式显示,更方便使用者逐页阅读,而最基本的指令就是按空白键(space)就往下一页显示,按 b 键就会往回(back)一页显示

more /var/log/dmesg查看 内核日志

在boot阶段,所有的应用还没有启动,syslogd也未启动,这时内核日志是非常重要的信息。除了设备初始化日志、内核模块日志,它还会包含一些应用崩溃的相关信息记录,了解dmesg的使用对于调试程序相当重要。

实际上等同于 dmesg | more

image-20220425172024171.png

head

head –n 文件名 查看文件头几行,n是行数

image-20220425172724391.png

tail

tail –n 文件名 查看文件尾几行,n是行数

image-20220425172803448.png

echo

echo于在shell中打印shell变量的值,或者直接输出指定的字符串。

$号后面为变量名

输出环境变量

image-20220425192056254.png

输出转义

image-20220425191943431.png

image-20220425192147699.png

grep

grep 命令用于查找文件里符合条件的字符串,可以上述其他文本查看命令联合使用,中间用|号分割

grep 匹配字符 目标文件

-n显示所在行号

-A往后多显示几行

-B往前多显示几行

-r目标为目录时需要指定

image-20220425200727871.png

文本处理

wc

wc指令我们可以计算文件的Byte数、字数、或是列数

image-20220425212342573.png

wc -l 获取指定文件的行数

wc获取行数、单词数、字节数

awk

AWK 是一种处理文本文件的语言,是一个强大的文本分析工具

将文件每行按空格或TAB分割,输出文本中的1、4项

awk '{print $1,$4}' 文件

结合使用wc和awk获取文件行数

wc获取行数为12、单词数12、字节数192

image-20220425212811341.png

由于获取到的行数后面还有文件名,我们只需要第一项,故再结合awk,取第一个单词

image-20220425212621427.png

文件属性修改

chown

chown 所属用户:所属组 文件/目录
  • -v : 显示详细的处理信息
  • -R : 递归处理处理指定目录以及其子目录下的所有文件

chgrp

与 chown 命令不同,chgrp 允许普通用户改变文件所属的组,只要该用户是该组的一员。

chgrp 组 文件/目录

chmod

修改文件的所有者:组:其他的权限

# 权限 rwx 二进制
7 读 + 写 + 执行 rwx 111
6 读 + 写 rw- 110
5 读 + 执行 r-x 101
4 只读 r– 100
3 写 + 执行 -wx 011
2 只写 -w- 010
1 只执行 –x 001
0 000
chmod 777 file

ifconfig

ifconfig命令用于显示或设置网络设备

image-20220425174506633.png

errors指的是网卡接收异常统计,这个值是从网卡上读取到的,并非内核计数,因此具体含义需要参考网卡的技术说明书,可以认为是接收到异常包,接收异常错误统计的总和。

dropped统计了网卡丢包数同时也包括网卡dev层的内核丢包,比如内核发现网卡传递过来的包是不支持的协议类型,那么就会丢弃该包同时增加该计数。

overruns指的是fifo被填满了从而导致的丢包量,当内核申请内存给网卡使用,如果被填满后,内核还没有来得及读取和清空数据,那么就会触发overrun,从而把第一个包丢弃掉。

frame指的是帧格式错误计数,一般是帧不符合要求,比如长度未进行8字节对齐,链路层帧中的crc校验错误等,很可能是网线或者网口异常引起。

设置环境变量

env

查看所有环境变量

image-20220425193426390.png

(延伸,c语言调用)stdlib

getenv

putenv 一个完整的字符串name=value,name已有则先删,再加入

setenv 两个参数分别传name value,rewrite不为0,name已有则和putenv一样,为0,name已有则不替换且不报错

unsetenv

echo $PATH

查看具体某个变量值,如查看path变量

export

效果与env类似

export 可新增,修改或删除环境变量,供后续执行的程序使用。export 的效力仅限于该次登陆操作。在当前命令行里新建bash,属于创建子进程,会继承环境变量

export -p //列出当前的环境变量值
export MYENV //定义环境变量
export MYENV=7 //定义环境变量并赋值
export -n MYENV //删除指定的变量。变量实际上并未删除,只是不会输出到后续指令的执行环境中。
export PATH=$PATH:你的路径 //特殊,新增path的值,因为path格式是路径1:路径2:路径3 export命令是对变量覆盖式赋值,因此注意先通过$PATH:把已有值取出,注意这种方式仅当前shell进程有效,每次ssh登陆会创建新的shell进程

image-20220425193758797.png

declare

用于声明 shell 变量,export命令实质上是调用此命令

declare [+/-][rxi][变量名称=设置值] 或 declare -f

参数说明

  • +/-  "-"可用来指定变量的属性,"+"则是取消变量所设的属性。
  • -f  仅显示函数。
  • r  将变量设置为只读。
  • x  指定的变量会成为环境变量,可供shell以外的程序来使用。
  • i  [固定变量类型]可以是数值,字符串或运算式,第一次赋值后,之后只能赋值同类型的值

注意

declare命令可以声明普通变量,也可以声明环境变量

export 变量 则相当于declare -x 变量

以上方法都是临时的

永久设置环境变量

  • 修改profile文件:
    vim /etc/profile
    在里面加入:
    export PATH="$PATH:新路径"
  • 修改.bashrc文件:
    vim ~/.bashrc
    在里面加入:
    export PATH="$PATH:新路径"

如果想立刻生效则需执行如下source命令

source

在当前Shell环境中从指定文件读取和执行命令,执行文件并从文件中加载变量及函数到执行环境

  • 在一些工具的执行过程中,会把环境变量设置以"export XXX=XXXXXX"或"declare XXX=XXXXXX"的形式导出到 一个文件中,然后用source加载该文件内容到执行环境中。
  • 读取和执行/root/.bashrc文件。
source ~/.bashrc

帮助文档

man

查看单独命令的帮助文档

man -k 命令名

image-20220425181457520.png

括号里的数字表示主题:
(1)用户命令
(2)系统调用
(3)库函数
(4)特殊文件
(5)文件格式
(6)游戏
(7)综合
(8)系统管理和特权命令
(9)内核接口(不是所有的linux发行版都包括)

可见chmod有1,2主题,我们现在查看主题1,使用如下命令

man 1 命令名

man 1 chmod

image-20220425181618547.png

info

文档树的形式显示帮助文档

info 命令名 # 直接查看指定命令的部分
info # 进入文档树

image-20220425181808985.png

用户管理

who

显示系统中有哪些使用者正在上面,显示的资料包含了使用者 ID、使用的终端机、从哪边连上来的、上线时间、呆滞时间、CPU 使用量、动作等等。

使用权限:所有使用者都可使用。

who显示当前登录系统的用户

image-20220425202435428.png

who -H 显示标题栏

image-20220425202515984.png

who -T

image-20220425202723775.png

关于 -T 选项的 ‘+、-、?’: ‘+’ 允许写入信息 ‘-‘ 禁止写入信息 ‘?’ 不能查找到终端设备

w

w命令用于显示目前登入系统的用户信息,与who类似

image-20220425203542184.png

进程管理

limit

查看线程状态

ps -T -p pid #查看某个进程的线程 -T表示thread -p表示process

top -H -p #实时查看某个进程的线程 -H表示显示树状结构,表示程序间的相互关系。 -p表示process

/proc/pid/task/tid #在这个文件中可以看到线程的状态,pid进程id,tid线程id

所以为了方便排查问题,最好指定线程名

系统管理

free

显示系统内存使用情况,包括物理内存、交互区内存(swap)和内核缓冲区内存

  1. 内核,进程,堆栈,独立?@问题

    image-20221018005835057

date

Linux date命令可以用来显示或设定系统的日期与时间,在显示方面,使用者可以设定欲显示的格式,

格式设定为一个+号后接数个标记

image-20220425211348381.png

image-20220425211522257.png

kill

发送信号

kill -l查看所有信号

image-20220425213356843.png

kill -信号数字 进程pid

pid可以通过ps命令获取

kill -9 进程pid常用来发送kill信号来强行杀死进程

ps

ps (英文全拼:process status)命令用于显示当前进程的状态,类似于 windows 的任务管理器。

  • -A 列出所有的进程
  • -e:此选项的效果和指定"A"选项相同。
  • u:以用户为主的格式来显示程序状况。
  • -f:显示UID,PPIP,CMD与STIME栏位。
  • -l或l:采用详细的格式来显示程序状况。
ps -l //将目前属于您自己这次登入的 PID 与相关信息列示出来

image-20220425214631542.png

ps -ef //显示所有命令,连带命令行
ps -ef | grep 进程关键字 //一般配合grep 进程关键字,用来找指定进程的pid
ps aux //显示所有当前终端机的程序,以用户为主,x显示所有终端机

image-20220425214749631.png

系统管理-开机自启的脚本

rc.local,较为古老的

ubuntu18.04及之后不再使用initd管理系统,改用systemd,但是initd仍被保留了。

/etc/rc.local(/etc/rc.d/rc.local)文件中添加,本质是有一个服务/etc/systemd/system/rc-local.service,服务里执行了rc.local文件。注意需要设置为x权限,否则会遇到无法启动的问题。

英文原义:RC (runcom,run command)
中文释义:含有程序(应用程序甚至操作系统)启动指令的脚本文件
注  解:这一文件在操作系统启动时会自动执行,它含有要运行的指令(命令或其它脚本)列表。

systemd 的service,新

可以自己新建systemd 服务管理系统的服务。在/etc/systemd/system/目录里新建

init.d目录,较为古老的

也可以往/etc/init.d/目录里创建脚本文件,注意需要有头信息,脚本里面如果写阻塞的命令,则注意命令后面加空格&,表示后台进程,nohup则持久化。设置脚本文件的执行权限。最后使用update-rc.d命令加入sudo update-rc.d test defaults 95

这条命令实际上是把脚本创建软连接到/etc/rc*.d/目录中,星号代表运行级别

image-20220602205953841

运行级别

  • 0 停机,关机
  • 1 单用户,无网络连接,不运行守护进程,不允许非超级用户登录
  • 2 多用户,无网络连接,不运行守护进程
  • 3 多用户,正常启动系统
  • 4 用户自定义
  • 5 多用户,带图形界面
  • 6 重启

K开头的脚本文件代表运行级别加载时需要关闭的,S开头的代表相应级别启动时需要执行,数字01代表顺序按照自己的需要相应修改即可。在你有多个启动脚本,而它们之间又有先后启动的依赖关系时你就知道这个数字的具体作用了。更多说明建议看man update-rc.d。

问题

功能测试宏是什么?

image-20220425161427268.png

大小单位很奇怪?

4096实际上是目录文件的默认分配大小,并不是实际已经用的大小,也不是目录里的文件总大小,除非目录包含文件太多,才会大于此值
image-20220425205203044.png

image-20220425215128354.png

du命令(disk usage)查看的是磁盘占用大小,不是文件大小

image-20220425215058005.png

stat -f与df看到的文件系统不同

/dev/下的文件所属文件系统很奇怪

stat中device是什么

stat命令其实是查看文件系统的inode信息
如果是普通文件,则是所在硬盘设备号,由主设备号+次设备号构成,后缀h/d分别代表十六进制hexadecimal和十进制decimalism
如fc02h则代表主设备号fc,次设备号02
file
通过df -T 命令查看当前文件所属文件系统为/dev/vda2
file
查看/dev/vda2的设备号,其中252,2正是此硬盘的主次设备号,用16进制表示为fc,02,合并起来则是fc02h,转为十进制则是64514d
file

多核,多进程,共享fd,文件内容会是什么?