git,系统/用户空间,Synchronized与ReentrantLock区别总结(简单粗暴,一目了然)

Git命令的背后 - 简书 (jianshu.com)

git init

使用git init初始化一个新的目录时,会生成一个.git的目录,该目录即为本地仓库。一个新初始化的本地仓库是这样的:

├── HEAD
├── branches
├── config
├── description
├── hooks
├── objects
│   ├── info
│   └── pack
└── refs
    ├── heads
    └── tags
  • description 用于GitWeb程序
  • config 配置特定于该仓库的设置(还记得git config的三个配置级别么)
  • hooks 放置客户端或服务端的hook脚本
  • HEAD 传说中的HEAD指针,指明当前处于哪个分支
  • objects Git对象存储目录
  • refs Git引用存储目录
  • branches 放置分支引用的目录

其中descriptionconfighooks这些不在讨论中,后文会直接忽略。

git add

Gitcommit之前先要通过git add添加文件,这个操作Git内部会做些什么呢?

执行如下操作:

  • echo "Hello Git" > a.txt生成一个a.txt
  • 再通过git add a.txt添加文件
  • 查看.git目录
├── HEAD
├── branches
├── index
├── objects
│   ├── 9f
│   │   └── 4d96d5b00d98959ea9960f069585ce42b1349a
│   ├── info
│   └── pack
└── refs
    ├── heads
    └── tags

可以看到,多了一个index文件。并且在objects目录下多了一个9f的目录,其中多了一个4d96d5b00d98959ea9960f069585ce42b1349a文件。

其实9f4d96d5b00d98959ea9960f069585ce42b1349a就是一个Git对象,称为blob对象

这个文件名(或者叫对象名)是怎样来的呢?简单的说,就是Git会先生成一个文件头,其中包含这个对象的类型(比如blob)和原始文件长度加上一个空字节。文件头再加上原始文件内容,然后算出一个SHA-1。这个SHA-1有40位,前两位会用于新建目录,后38位用于文件名。所以,完整的对象名应该把上一级目录名给包含进去的。

可以通过Git的底层命令git cat-file -p查看其内容:

$ git cat-file -p 9f4d96d5b00d98959ea9960f069585ce42b1349a
Hello Git

可以看到,其中的内容和a.txt文件是一模一样的。

通过git cat-file -t查看对象的类型:

$ git cat-file -t 9f4d96d5b00d98959ea9960f069585ce42b1349a
blob

确实是blob类型。那index文件又是什么鬼?

内核空间和用户空间

现在操作系统都是采用虚拟存储器,那么对 32 位操作系统而言,它的寻址空间(虚拟地址空间,或叫线性地址空间)为 4G(2的32次方)。也就是说一个进程的最大地址空间为 4G。操作系统的核心是内核(kernel),它独立于普通的应用程序,可以访问受保护的内存空间,也有访问底层硬件设备的所有权限。为了保证保证内核的安全,现在的操作系统一般都强制用户进程不能直接操作内核。具实现方式基本都是由操作系统将虚拟地址空间划分为两部分,一部分为内核空间,一部分为用户空间。针对 Linux 操作系统而言,最高的 1G 字节(从虚拟地址 0xC0000000 到 0xFFFFFFFF),供内核使用,称为内核空间。而较低的 3G 字节(从虚拟地址 0x00000000 到 0xBFFFFFFF)由各个进程使用,称为用户空间。
每个进程的 4G 地址空间中,最高 1G 都是一样的,即内核空间。只有剩余的 3G 才归进程自己使用。换句话说就是, 最高 1G 的内核空间是被所有进程共享的!

 

 

进程切换

为了控制进程的执行,内核必须有能力挂起正在CPU上运行的进程,并恢复以前挂起的某个进程的执行。这种行为被称为进程切换(process switch)、任务切换(task switch)或上下文切换(content switch)。因此可以说,任何进程都是在操作系统内核的支持下运行的,是与内核紧密相关的。

从一个进程的运行转到另一个进程上运行,这个过程中经过下面这些变化:
1. 保存处理机上下文,包括程序计数器和其他寄存器。
2. 更新PCB信息。
3. 把进程的PCB移入相应的队列,如就绪、在某事件阻塞等队列。
4. 选择另一个进程执行,并更新其PCB。
5. 更新内存管理的数据结构。
6. 恢复处理机上下文。

注:总而言之进程间的切换就是很耗费系统资源

 

进程的阻塞

正在执行的进程,由于期待的某些事件未发生,如请求系统资源失败、等待某种操作的完成、新数据尚未到达或无新工作做等,则系统将自动执行阻塞(Block),使自己由运行状态变为阻塞状态。所以进程的阻塞,是进程自身的一种主动行为,也只有处于运行态的进程(获得CPU),才可能将其转为阻塞状态。当进程进入阻塞状态,是不占用CPU资源的

 

文件描述符fd

文件描述符(File descriptor)是一个用于描述指向文件的引用。文件描述符在形式上是一个非负整数。实际上,它是一个索引值,指向内核中为每一个进程打开文件的记录表。当程序打开一个现有文件或者创建一个新文件时,内核向进程返回一个文件描述符。

 

缓存 I/O

缓存 I/O 又被称作标准 I/O,大多数文件系统的默认 I/O 操作都是缓存 I/O。在 Linux 的缓存 I/O 机制中,操作系统会将 I/O 的数据缓存在文件系统的页缓存( page cache )中,也就是说,数据会先被拷贝到操作系统内核的缓冲区中,然后才会从操作系统内核的缓冲区拷贝到应用程序的地址空间。

缓存 I/O 的缺点:
数据在传输过程中需要在应用程序地址空间和内核进行多次数据拷贝操作,这些数据拷贝操作所带来的 CPU 以及内存开销是非常大的。

 

栗子: 一次IO访问(以read举例),数据会先被拷贝到操作系统内核的缓冲区中,然后才会从操作系统内核的缓冲区拷贝到应用程序的地址空间。所以说,当一个read操作发生时,它会经历两个阶段:
1. 等待数据准备 (Waiting for the data to be ready)
2. 将数据从内核拷贝到进程中 (Copying the data from the kernel to the process)

因此linux系统产生五种网络模式的方案。
- 阻塞 I/O(blocking IO)
- 非阻塞 I/O(nonblocking IO)
- I/O 多路复用( IO multiplexing)
- 信号驱动 I/O( signal driven IO)
- 异步 I/O(asynchronous IO)

 

阻塞:

一个函数在等待某些事情的返回值的时候会被 阻塞. 函数被阻塞的原因有很多: 网络I/O,磁盘I/O,互斥锁等.事实上 每个 函数在运行和使用CPU的时候都或多或少都会被阻塞(举个极端的例子来说明为什么对待CPU阻塞要和对待一般阻塞一样的严肃: 比如密码哈希函数 bcrypt, 需要消耗几百毫秒的CPU时间,这已经远远超过了一般的网络或者磁盘请求时间了).

 

异步:函数在会在某些事情完成之前就返回,仅需在函数中触发这个事情的调用即可,而不再关心执行结果如何

 

 

IO多路复用

IO多路复用是指内核一旦发现进程指定的一个或者多个IO条件准备读取,它就通知该进程。 多路复用最高效的是:它能同时等待多个文件描述符,而这些文件描述符(套接字描述符)其中的任意一个进入读就绪状态, select()函数就可以返回。

IO多路复用适用如下场合:

  (1)当客户端需要同时处理多个文件描述符的输入输出操作时(一般是交互式输入和网络套接口),必须使用I/O复用。

  (2)当程序需要同时进行多个套接字的操作的时候。

  (3)如果一个TCP服务器既要处理监听套接口,又要处理已连接套接口,一般也要用到I/O复用。

  (4)如果一个服务器需要同时使用TCP和UDP协议。

  (5)如果一个服务器要处理多个服务或者多个协议的时候。

  与多进程和多线程技术相比,I/O多路复用技术的最大优势是系统开销小,系统不必创建进程/线程,也不必维护这些进程/线程,从而大大减小了系统的开销。

  select/poll是顺序扫描fd是否就绪,而且支持的fd数量有限。而epoll使用基于事件驱动方式代替顺序扫描,因此性能更高。当fd就绪时,立即回调函数rollback。



 

select、poll、epoll都是IO多路复用的机制,I/O多路复用就是通过一种机制,让一个进程可以监视多个描述符的读/写等事件,一旦某个描述符就绪(一般是读或者写事件发生了),就能够通知程序进行相应的读写操作。但select,epoll本质上都是同步I/O,因为他们都需要在读写事件就绪后自己负责进行读写,也就是说这个读写过程是阻塞的,而异步I/O则无需自己负责进行读写,异步I/O的实现会负责把数据从内核拷贝到用户空间。

 

select

select 函数监视的文件描述符分3类,分别是writefds、readfds、和exceptfds。调用后select函数会阻塞,直到有描述符就绪(有数据 可读、可写、或者有except),或者超时函数返回(timeout指定等待时间,如果立即返回设为null即可),当select函数返回后,可以通过遍历fdset,来找到就绪的描述符。

select目前几乎在所有的平台上支持,其良好跨平台支持也是它的一个优点。select的一个缺点在于单个进程能够监视的文件描述符的数量存在最大限制,在Linux上一般为1024,可以通过修改宏定义甚至重新编译内核的方式提升这一限制,但是这样也会造成效率的降低。

select本质上是通过设置或者检查存放fd标志位的数据结构来进行下一步处理。这样所带来的缺点是:

  1. select最大的缺陷就是单个进程所打开的FD是有一定限制的,它由FD_SETSIZE设置,默认值是1024。一般来说这个数目和系统内存关系很大,具体数目可以cat /proc/sys/fs/file-max察看。32位机默认是1024个。64位机默认是2048.

 

  1. 对socket进行扫描时是线性扫描,即采用轮询的方法,效率较低。当套接字比较多的时候,每次select()都要通过遍历FD_SETSIZE个Socket来完成调度,不管哪个Socket是活跃的,都遍历一遍。这会浪费很多CPU时间。(如果能够给套接字注册某个回调函数,当他们活跃时自动完成相关操作,就避免了轮训,这正是epoll与kqueue做的。)

  3. 需要维护一个用来存放大量fd的数据结构,这样会使得用户空间和内核空间在传递该结构时复制开销大。

 

简单版:

  1. 连接数受限
  2. 查找配对速度慢
  3. 数据由内核拷贝到用户态

 

 

 

poll

基本原理:poll本质上和select没有区别,它将用户传入的数组拷贝到内核空间,然后查询每个fd对应的设备状态,如果设备就绪则在设备等待队列中加入一项并继续遍历,如果遍历完所有fd后没有发现就绪设备,则挂起当前进程,直到设备就绪或者主动超时,被唤醒后它又要再次遍历fd。这个过程经历了多次无谓的遍历。

它没有最大连接数的限制,原因是它是基于链表来存储的,但是同样有一个缺点:

  1. 大量的fd数组被整体复制于用户态和内核地址空间之间,而不管这样的复制是不是有意义。

  2. poll还有一个特点是“水平触发”如果报告了fd后,没有被处理,那么下次poll时会再次报告该fd。

  3. poll改善了select的第一个连接数受限的缺点

注意:select和poll都需要在返回后,通过遍历文件描述符来获取已经就绪的socket。事实上,同时连接的大量客户端在同一时刻可能只有很少的处于就绪状态,因此随着监视的描述符数量的增长,其效率也会线性下降。




epoll

基本原理:在 select/poll中,进程只有在调用一定的方法后,内核才对所有监视的文件描述符进行扫描,而epollepoll更加灵活,没有描述符限制,事先通过epoll_ctl()来注册一个文件描述符,使用一个文件描述符管理多个描述符,将用户关系的文件描述符的事件存放到内核的一个事件表中,这样在用户空间和内核空间的copy只需一次。一旦基于某个文件描述符就绪时,内核会采用类似callback的回调机制,迅速激活这个文件描述符,当进程调用epoll_wait() 时便得到通知。(此处去掉了遍历文件描述符,而是通过监听回调的的机制。这正是epoll的魅力所在。)

 

epoll的优点:

  1. 监视的描述符数量不受限制,没有最大并发连接的限制,能打开的FD的上限远大于1024(1G的内存上能监听约10万个端口,具体数目可以 cat /proc/sys/fs/file-max察看)。

  2. 效率提升,不是采用轮询的方式,IO的效率不会随着监视fd的数量的增长而下降,只有活跃可用的FD才会调用callback函数。即:epoll最大的优点在于它只关心“活跃”的连接,而跟连接总数无关,因此在实际的网络环境中,epoll的效率就会远远高于select和poll。

  3. 内存拷贝,利用mmap()文件映射内存加速与内核空间的消息传递;即epoll使用mmap减少了复制的开销。

 

epoll对文件描述符的操作:

LT模式:默认模式,当epoll_wait检测到描述符事件发生并将此事件通知应用程序,应用程序可以不立即处理该事件。下次调用epoll_wait时,会再次响应应用程序并通知此事件。

     .socket的接收缓冲区状态变化时触发读事件,即空的接收缓冲区刚接收到数据时触发读事件

     .socket的发送缓冲区状态变化时触发写事件,即满的缓冲区刚空出空间时触发读事件

     仅在缓冲区状态变化时触发事件,比如数据缓冲去从无到有的时候(不可读-可读)

LT(level triggered)是缺省的工作方式,并同时支持block和no-block socket。此种模式下,内核会告诉你一个文件描述符是否就绪了,然后你可以对这个就绪的fd进行IO操作。如果你不做任何操作,内核还是会继续通知你。

 

ET模式:当epoll_wait检测到描述符事件发生并将此事件通知应用程序,应用程序必须立即处理该事件。如果不处理,下次调用epoll_wait时,不会再次响应应用程序并通知此事件。

     .socket接收缓冲区不为空,有数据可读,则读事件一直触发

     .socket发送缓冲区不满可以继续写入数据,则写事件一直触发

      epoll_wait返回的事件就是socket的状态

ET(edge-triggered)是高速工作方式,只支持no-block socket。在这种模式下,当描述符从 未就绪变为就绪时,内核会通过epoll告诉你,然后它会假设你知道文件描述符已经就绪,并且不会再为那个文件描述符发送更多的就绪通知,直到你做了某些操作导致那个文件描述符不再为就绪状态了(比如,你在发送,接收或者接收请求,或者发送接收的数据少于一定量时导致了一个EWOULDBLOCK 错误)。但是请注意,如果一直不对这个fd执行IO操作(从而导致它再次变成未就绪),内核不会发送更多的通知(only once)。ET模式在很大程度上减少了epoll事件被重复触发的次数,因此效率要比LT模式高。epoll工作在ET模式的时候,必须使用非阻塞套接口,以避免由于一个文件句柄的 阻塞读/阻塞写操作 把处理多个文件描述符的任务阻塞。
 


epoll解决的问题:
  • epoll没有fd数量限制,每个epoll监听一个fd,所以最大数量与能打开的fd数量有关,一个g的内存的机器上,能打开10万个左右

  • epoll不需要每次都从用户空间将fd set复制到内核空间,epoll在用epoll_ctl函数进行事件注册的时候,已经将fd复制到内核中,所以不需要每次都重新复制一次

  • select 和 poll 都是主动轮询机制,需要拜訪每一個 FD; epoll是被动触发方式,给fd注册了相应事件的时候,我们为每一个fd指定了一个回调函数,当数据准备好之后,就会把就绪的fd加入一个就绪的队列中,epoll_wait的工作方式实际上就是在这个就绪队列中查看有没有就绪的fd,如果有,就唤醒就绪队列上的等待者,然后调用回调函数。

  • 虽然epoll。poll。epoll都需要查看是否有fd就绪,但是epoll之所以是被动触发,就在于它只要去查找就绪队列中有没有fd,就绪的fd是主动加到队列中,epoll不需要一个个轮询确认。 换一句话讲,就是select和poll只能通知有fd已经就绪了,但不能知道究竟是哪个fd就绪,所以select和poll就要去主动轮询一遍找到就绪的fd。而epoll则是不但可以知道有fd可以就绪,而且还具体可以知道就绪fd的编号,所以直接找到就可以,不用轮询。

 

 select、poll、epoll区别

 select和epoll都是I/O多路复用的方式,但是select是通过不断轮询监听socket实现,epoll是当socket有变化时通过回掉的方式主动告知用户进程实现

  1. 支持一个进程所能打开的最大连接数
输入图片说明
 
 
 
  1. FD剧增后带来的IO效率问题
输入图片说明
 
 
 
  1. 消息传递方式
输入图片说明
 
 

使用场景:

  1. 表面上看epoll的性能最好,但是在连接数少并且连接都十分活跃的情况下,select和poll的性能会比epoll好,毕竟epoll的通知机制需要很多函数回调。

  2. select低效是因为每次它都需要轮询,而epoll采用的是被动触发方式,只需要查看就绪队列中是否加入了新成员即可。

  3. select, poll是为了解決同时大量IO的情況(尤其网络服务器),但是随着连接数越多,性能越差
  4. epoll是select和poll的改进方案,在 linux 上可以取代 select 和 poll,可以处理大量连接的性能问题

Synchronized与ReentrantLock区别总结(简单粗暴,一目了然)_zxd8080666的博客-CSDN博客_reentrantlock和synchronized区别

这篇文章是关于这两个同步锁的简单总结比较,关于底层源码实现原理没有过多涉及,后面会有关于这两个同步锁的底层原理篇幅去介绍。

相似点:

这两种同步方式有很多相似之处,它们都是加锁方式同步,而且都是阻塞式的同步,也就是说当如果一个线程获得了对象锁,进入了同步块,其他访问该同步块的线程都必须阻塞在同步块外面等待,而进行线程阻塞和唤醒的代价是比较高的(操作系统需要在用户态与内核态之间来回切换,代价很高,不过可以通过对锁优化进行改善)。

功能区别:

这两种方式最大区别就是对于Synchronized来说,它是java语言的关键字,是原生语法层面的互斥,需要jvm实现。而ReentrantLock它是JDK 1.5之后提供的API层面的互斥锁,需要lock()和unlock()方法配合try/finally语句块来完成

便利性:很明显Synchronized的使用比较方便简洁,并且由编译器去保证锁的加锁和释放,而ReenTrantLock需要手工声明来加锁和释放锁,为了避免忘记手工释放锁造成死锁,所以最好在finally中声明释放锁。

锁的细粒度和灵活度:很明显ReenTrantLock优于Synchronized

性能的区别:

在Synchronized优化以前,synchronized的性能是比ReenTrantLock差很多的,但是自从Synchronized引入了偏向锁,轻量级锁(自旋锁)后,两者的性能就差不多了,在两种方法都可用的情况下,官方甚至建议使用synchronized,其实synchronized的优化我感觉就借鉴了ReenTrantLock中的CAS技术。都是试图在用户态就把加锁问题解决,避免进入内核态的线程阻塞。

1.Synchronized

Synchronized进过编译,会在同步块的前后分别形成monitorenter和monitorexit这个两个字节码指令。在执行monitorenter指令时,首先要尝试获取对象锁。如果这个对象没被锁定,或者当前线程已经拥有了那个对象锁,把锁的计算器加1,相应的,在执行monitorexit指令时会将锁计算器就减1,当计算器为0时,锁就被释放了。如果获取对象锁失败,那当前线程就要阻塞,直到对象锁被另一个线程释放为止。

  1. public class SynDemo{
  2.  
  3. public static void main(String[] arg){
  4. Runnable t1=new MyThread();
  5. new Thread(t1,"t1").start();
  6. new Thread(t1,"t2").start();
  7. }
  8.  
  9. }
  10. class MyThread implements Runnable {
  11.  
  12. @Override
  13. public void run() {
  14. synchronized (this) {
  15. for(int i=0;i<10;i++)
  16. System.out.println(Thread.currentThread().getName()+":"+i);
  17. }
  18.  
  19. }
  20.  
  21. }


2.ReentrantLock

由于ReentrantLock是java.util.concurrent包下提供的一套互斥锁,相比Synchronized,ReentrantLock类提供了一些高级功能,主要有以下3项:

        1.等待可中断,持有锁的线程长期不释放的时候,正在等待的线程可以选择放弃等待,这相当于Synchronized来说可以避免出现死锁的情况。通过lock.lockInterruptibly()来实现这个机制。

        2.公平锁,多个线程等待同一个锁时,必须按照申请锁的时间顺序获得锁,Synchronized锁非公平锁,ReentrantLock默认的构造函数是创建的非公平锁,可以通过参数true设为公平锁,但公平锁表现的性能不是很好。

公平锁、非公平锁的创建方式:

  1. //创建一个非公平锁,默认是非公平锁
  2. Lock lock = new ReentrantLock();
  3. Lock lock = new ReentrantLock(false);
  4.  
  5. //创建一个公平锁,构造传参true
  6. Lock lock = new ReentrantLock(true);

        3.锁绑定多个条件,一个ReentrantLock对象可以同时绑定对个对象。ReenTrantLock提供了一个Condition(条件)类,用来实现分组唤醒需要唤醒的线程们,而不是像synchronized要么随机唤醒一个线程要么唤醒全部线程

ReenTrantLock实现的原理:

之后还会总结一篇ReenTrantLock相关的原理底层原理分析,简单来说,ReenTrantLock的实现是一种自旋锁,通过循环调用CAS操作来实现加锁。它的性能比较好也是因为避免了使线程进入内核态的阻塞状态。想尽办法避免线程进入内核的阻塞状态是我们去分析和理解锁设计的关键钥匙。

什么情况下使用ReenTrantLock:

答案是,如果你需要实现ReenTrantLock的三个独有功能时。

ReentrantLock的用法如下:

  1. public class SynDemo{
  2.  
  3. public static void main(String[] arg){
  4. Runnable t1=new MyThread();
  5. new Thread(t1,"t1").start();
  6. new Thread(t1,"t2").start();
  7. }
  8.  
  9. }
  10. class MyThread implements Runnable {
  11.  
  12. private Lock lock=new ReentrantLock();
  13. public void run() {
  14. lock.lock();
  15. try{
  16. for(int i=0;i<5;i++)
  17. System.out.println(Thread.currentThread().getName()+":"+i);
  18. }finally{
  19. lock.unlock();
  20. }
  21. }
  22.  
  23. }

对ReentrantLock的源码分析这有一篇很好的文章

http://www.blogjava.net/zhanglongsr/articles/356782.html

后续在补充的问题:

Synchronized的原理

ReentrantLock的原理。

ReentrantLock为什么是可重入的。

公平锁和非公平锁是什么?有什么区别。

 

 

posted @ 2021-12-13 00:29  CharyGao  阅读(31)  评论(0编辑  收藏  举报