{"id":17210671,"url":"https://github.com/justson/pratical","last_synced_at":"2025-09-14T03:22:42.088Z","repository":{"id":86015355,"uuid":"81713752","full_name":"Justson/Pratical","owner":"Justson","description":"The repository main put personal pratical code","archived":false,"fork":false,"pushed_at":"2017-03-26T05:57:13.000Z","size":37,"stargazers_count":1,"open_issues_count":0,"forks_count":0,"subscribers_count":1,"default_branch":"master","last_synced_at":"2025-03-25T12:16:36.684Z","etag":null,"topics":[],"latest_commit_sha":null,"homepage":null,"language":"Java","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":null,"status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/Justson.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":null,"funding":null,"license":null,"code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null}},"created_at":"2017-02-12T08:48:30.000Z","updated_at":"2023-09-08T17:20:48.000Z","dependencies_parsed_at":"2023-05-25T09:15:06.859Z","dependency_job_id":null,"html_url":"https://github.com/Justson/Pratical","commit_stats":null,"previous_names":[],"tags_count":0,"template":false,"template_full_name":null,"purl":"pkg:github/Justson/Pratical","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/Justson%2FPratical","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/Justson%2FPratical/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/Justson%2FPratical/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/Justson%2FPratical/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/Justson","download_url":"https://codeload.github.com/Justson/Pratical/tar.gz/refs/heads/master","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/Justson%2FPratical/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":275056210,"owners_count":25397803,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2022-07-04T15:15:14.044Z","status":"online","status_checked_at":"2025-09-14T02:00:10.474Z","response_time":75,"last_error":null,"robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":true,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":[],"created_at":"2024-10-15T02:54:59.871Z","updated_at":"2025-09-14T03:22:42.067Z","avatar_url":"https://github.com/Justson.png","language":"Java","funding_links":[],"categories":[],"sub_categories":[],"readme":"## \u003ccenter/\u003eJava线程安全与程序性能\n\n### 定义\n\t\t线程安全:当多个线程访问某个类的时候,不管运行时采用何种调度方式或者这些线程如何交替执行并且在\n\t这主调代码中不需要任何额外的同步和协同,这个类都能表现出正确的行为, 那么就称这个类为线程安全.\n\t\t\n      \n\t\t并发:并发当有多个线程在操作时,如果系统只有一个CPU,则它根本不可能真正同时进行一个以上的线程，\n\t它只能把CPU运行时间划分成若干个时间段,再将时间 段分配给各个线程执行，在一个时间段的线程代码运行时\n\t，其它线程处于挂起状。\n\t\n\t\n### 简单说明\n线程安全可能是非常复杂的,在没有足够的同步下,多线程执行的顺序是不可预测的,如下在单线程执行时没有任何问题的, 但是多线程执行时可能会出现两个或者多个值一致.\n\n```\npublic class Unsafe {\n\t\n\tint a=0;\n\tpublic int getA(){\n\t\treturn a++;\n\t}\n\t\n}\n```\n因为在多个线程并发中, 对a++进行操作需要三步:读-改-写,首先在主存中读取a的值进入线程工作内存中,然后对a进行++操作, 最后写回主存中.假设线程1,2,3读取a的值都为10,那么线程1,2,3得到a++的值都会是11,因为这个过程中程序都是并行执行, 并不是串行.\n以上最简单的解决方式就是在int前面加上隐式同步锁synchronized,但是这种方式并不高效,每个线程需要执行a++操作, 都需要获取Unsafe实例的锁, 竞争锁失败的线程都会被JVM系统挂起,线程1切换至线程2,我们称之为上下文切换, 这种行为需要系统内核到底层把线程2唤醒, 开销太大.有没有更好方式解决这个问题,不需要劳烦到系统底层呢, 答案是有的,如下\n\n```\nprivate AtomicInteger mAtomicInteger=new AtomicInteger();\n\tprivate volatile int a=0;\n\tpublic Safe(){\n\t\tmAtomicInteger.set(a);\n\t}\n\tpublic int getA(){\n\t\tfor(;;){\n\t\t\t\n\t\t\tif(mAtomicInteger.compareAndSet(mAtomicInteger.get(), mAtomicInteger.get()+1)){\n\t\t\t\treturn mAtomicInteger.get();\n\t\t\t}\n\t\t}\n\t}\n```\nCAS(CompareAndSwap)是一种比较交换,通过循环来保证线程安全的乐观锁.竞争失败的线程并不会被系统挂起, 而是通过自旋(Self-Spin)不断竞争直到成功返回.\n\n### 性能\n\t主要从可伸缩性,(吞吐率(处理能力)),(服务时间, 延迟时间 (运行速度)) 展开~\n**多线程性能需要建立线程安全基础上, 离开了线程安全线程性能变得没有意义.\n\u003eAmdahl定律 \n\u003eS\u003c=1/(1-a+a/n)\n\u003e\n\u003e其中，a为并行计算部分所占比例，n为并行处理结点个数。这样，当1-a=0时，(即没有串行，只有并行)最大加速比s=n；当a=0时（即只有串行，没有并行），最小加速比s=1；当n→∞时，极限加速比s→ 1/（1-a），这也就是加速比的上限。例如，若串行代码占整个代码的25%，则并行处理的总体性能不可能超过4。这一公式已被学术界所接受，并被称做“阿姆达尔定律”。\n\n根据Amdahl定律可知,程序性能是与串行,并行执行息息相关的,但是糟糕的程序设计并行会带来性能开销,不会提升性能如下说明\n\n```\n// 并行程序\npublic class Person {\n\tprivate static void sleep(long time){\n\t\ttry{\n\t\t\tThread.sleep(time);\n\t\t}catch (Exception e) {\n\t\t\t// TODO: handle exception\n\t\t}\n\t}\n\tpublic synchronized void takeATurnRound(){\n\t\t//run \n\t\tsleep(1000*60*10);\n\t}\n\tpublic static void main(String[]arg){\n\t\tPerson mPerson=new Person();\n\t\tExecutorService mExecutorService = Executors.newCachedThreadPool();\n\t\t for(int i=0;i\u003c3;i++){\n\t\t\t \n\t\t\t mExecutorService.submit(()-\u003e{\n\t\t\t\t mPerson.takeATurnRound();\n\t\t\t });\n\t\t }\n\t\t mExecutorService.shutdown();\n\t}\n\t\n}\n\n```\n```\n//串行程序\npublic class Person {\n\tprivate static void sleep(long time){\n\t\ttry{\n\t\t\tThread.sleep(time);\n\t\t}catch (Exception e) {\n\t\t\t// TODO: handle exception\n\t\t}\n\t}\n\tpublic  void takeATurnRound(){\n\t\tsleep(1000*60*10);\n\t}\n\tpublic static void main(String[]arg){\n\t\tPerson mPerson=new Person();\n\t\tmPerson.takeATurnRound();\n\t\tmPerson.takeATurnRound();\n\t\tmPerson.takeATurnRound();\n\t}\n\t\n}\n```\n上面程序简单演示了一个人跑了三圈,这种并行程序设计严重影响程序性能,可伸缩性极低,不如串行程序,当并发数增加的时候计算能力反而下降了.\n\n#### 提高可伸缩性\n上面例子可以知道可伸缩最大的威胁就是独占方式的资源锁,所以我们可以通过减少竞争来提升可伸缩性,通过如下方式来减少竞争\n\n* 缩小锁的范围\n\n ```\n //错误做法\n public class PasswordStore {\n\tprivate HashMap\u003cString,String\u003emPasswordMap=new HashMap\u003c\u003e();\n\tpublic synchronized boolean storePassword(String name,String password){\n\t\tboolean tag=false;\n\t\tif(password==null||password.length()\u003c6)\n\t\t\treturn tag;\n\t\tString mPasswordKey=name;\n\t\tString passwordEncord=doCrypto(password);\n\t\ttry{\n\t\t\t//储存密码\n\t\t\tmPasswordMap.put(mPasswordKey, passwordEncord);\n\t\t\ttag=true;\n\t\t}catch(Exception e){\n\t\t\ttag=false;\n\t\t}\n\t\treturn tag;\n\t}\n\t//加密\n\tprivate String doCrypto(String password){\n\t\treturn password;\n\t}\n}\n ```\n \n ```\n //正确做法\n public class PasswordStore {\n\tprivate HashMap\u003cString,String\u003emPasswordMap=new HashMap\u003c\u003e();\n\tpublic boolean storePassword(String name,String password){\n\t\tboolean tag=false;\n\t\tif(password==null||password.length()\u003c6)\n\t\t\treturn tag;\n\t\tString mPasswordKey=name;\n\t\tString passwordEncord=doCrypto(password);\n\t\ttry{\n\t\t\tsynchronized (this) {\n\t\t\t\tmPasswordMap.put(mPasswordKey, passwordEncord);\n\t\t\t\treturn true;\t\n\t\t\t}\n\t\t\t\n\t\t}catch(Exception e){\n\t\t\treturn false;\n\t\t}\n\t\treturn tag;\n\t}\n\t//加密\n\tprivate String doCrypto(String password){\n\t\treturn password;\n\t}\n}\n ```\n \n 通过缩小storePassword方法锁的范围,极大的减少了持有锁执行指令数量,根据Amdahl定律,减少串行代码增加并行代码量可以提升可伸缩性.在实际中其实我们可以使用ConcurrentHashMap来代替HashMap达到线程安全,以及代替HashTable提升锁性能,因为ConcurrentHashMap使用了锁分段Segment等技术, 控制粒度都处理的很好.不得不佩服创造ConcurrentHashMap这位大神Doug Lea!\n \n* 减少锁的粒度(锁分解)\n\t\t\n\t```\n\t\t\n\t\tpublic class LockDecomposed {\n\t\t\tprivate List\u003cString\u003ebooks=new ArrayList\u003cString\u003e();\n\t\t\tprivate Set\u003cString\u003e mEmployee=new HashSet\u003c\u003e();\n\t\t\tpublic void storeBook(String book){\n\t\t\t/**\n\t\t\t * 错误加锁方式\n\t\t\t * synchronized (this) {\n\t\t\t *\tbooks.add(book);\n\t\t\t *}\n\t\t\t */\n\t\t\tsynchronized (books) {\n\t\t\tbooks.add(book);\n\t\t\t}\n\t\t}\n\t\tpublic void addStaff(String name){\n\t\t\t/**\n\t\t\t *错误加锁方式\n\t\t\t * synchronized (this) {\n\t\t\t *\tmEmployee.add(name);\n\t\t\t *}\n\t\t\t */\n\t\t\tsynchronized (mEmployee) {\n\t\t\t\tmEmployee.add(name);\n\t\t\t}\n\t\t}\t\n\t}\n\n\t\t\n\t```\n\t\n\n\t\t\n\n\t以上只是简单例子,可能不符合单一职责原则.如果synchronize(this)锁住的对象为添加员工和储存\n\t书本\t时候线程会发生竞争, 竞争时候的线程会被挂起, 然后等待, 等待结束被唤醒在加入系统的线\n\t程调度队列中,通过锁分解把没必要开销去除\n\n* 锁分段\n\n\t\t参考ConcurrentHashMap.\n\t\t通过Segment把竞争性缩小\t\n\n* 使用共享锁代替独占锁\n\n\t每年春运让人又爱有恨啊,以下模拟下买票系统来说明共享锁和独占锁\n\t首先进行简单的说明 , 以下用了JDK提供的共享锁 ReentrantReadWriteLock,\n\tReentrantReadWriteLock是JDK(concurrent包下的实现类,内部包含ReadLock和\tWriteLock)ReadLock和WriteLock 有什么区别呢?WriteLock可以理解为独占锁,ReadLock才是共\t享锁的体现, ReentrantReadWriteLock把他们封装在一块,执WriteLock.lock(),TickerServer\t内部只有单线程,所有读取线程会被Park挂起,当前线程执行\tReadLock.lock()当前线程会往后看看还有没有跟自己一样以SHAED模式被Park(挂起)起来的线程(存放\tAQS里面双向链表),没有往下执行查询逻辑, 有的话会唤醒它(被唤醒的线程做同样的逻辑唤醒它下面的线\t程)并行查询票.\n\t\n\t其实啊ReentrantReadWriteLock内部组合着一个很重要的变量Syn, Syn 是\tReentrantReadWriteLock的一个实现AQS的内部类,实际上WriteLock.lock和ReadLock.lock的逻\t辑加锁都会交给Syn去完成.Syn会调用父类AQS去完成共享锁和独占锁.AQS可以说是Doug Lea一大杰作,\t是\tJava JDK的concurrent这个包下的核心类.\n\n\n\n\t独占锁实现的方式如下\n\n```\n\t\t\t\t\n\t\t\t\t\n\t public class TicketServer {\n    private static final List\u003cTicketBean\u003e mTicketBeans = new ArrayList\u003c\u003e();\n    private static TicketServer mTicketServer = null;\n    private Random mRandom = null;\n    /**\n     * 默认有一万张票\n     */\n    static {\n        for (int i = 0; i \u003c 10000; i++) {\n            mTicketBeans.add(new TicketBean(i + 1));\n        }\n    }\n    private TicketServer() {\n        mRandom = new Random();\n    }\n    public List\u003cTicketBean\u003e queryTicket() {\n        return mTicketBeans;\n    }\n    public synchronized int queryTicketNumber() {\n        System.out.println(\" queryTicketNumber  current size:\" + mTicketBeans.size() + \"   thread:\" + Thread.currentThread().getName());\n        try {\n            Thread.sleep(10);//做耗时的logic\n        } catch (InterruptedException e) {\n            e.printStackTrace();\n        }\n        return mTicketBeans.size();\n    }\n   \n    /**单例模式**/\n    public static TicketServer getInstance() {\n        if (mTicketServer == null) {\n            synchronized (TicketServer.class) {\n                if (mTicketServer == null) {\n                    mTicketServer = new TicketServer();\n                }\n            }\n        }\n        return mTicketServer;\n    }\n    /**加锁模拟买票系统**/\n    public synchronized TicketBean buyTicket(String name, double price) {\n\n        int position = -1;\n        int size = mTicketBeans.size();\n        if (size \u003c= 0) {//查询一下有没有票\n            return null;\n        } else {\n            position = mRandom.nextInt(size);//随机一张票\n        }\n        TicketBean mTicketBean = mTicketBeans.get(position);\n        if (price \u003c mTicketBean.getTicketMoney()) {//判断要付的金额\n            return null;\n        }\n        mTicketBeans.remove(position);//系统移除该票, 改票已经卖出来了\n        mTicketBean.setTicketBelong(name);//设置一下票的所属者\n        mTicketBean.setTicketKey(Thread.currentThread().getName());\n        System.out.println(\" buy  current size:\" + mTicketBeans.size() + \"   Thread:\" + Thread.currentThread());\n        return mTicketBean;\n    }\n}\n \t\t\t\t\n```\n上面独占锁的方式实现卖票模拟器, synchronized的对象是TicketServer, 所以无论是执行买票逻辑还是进行查询剩余的票数的时候内部都只有一个线程在执行,来达到线程安全, 但是这种做法是不提倡,因为这样\t做线程的可伸缩性非常死, 提高并发量的时候性能并没有提升.\n\n```\n/**\n * \n * @author cenxiaozhong\n * 以共享锁模式卖票服务器,也就是说对查询票的数量允许\n * 多个线程进入查询, 但是对卖票必须进行锁定,只有一个线程\n * 可以进行对票操作\n */\npublic class TicketServer {\n\tprivate Random mRandom = null;\n\t//票容器\n\tprivate static final List\u003cTicketBean\u003e mTicketBeans = new ArrayList\u003c\u003e();\n\tprivate static TicketServer mTicketServer = null;\n\t// 原子类\n\tprivate static final AtomicReference\u003cTicketServer\u003e ATOMIC_REFERENCE = new AtomicReference\u003cTicketServer\u003e();\n\t//共享锁  JDK提供, author Doug Lea\n\tprivate ReentrantReadWriteLock mReentrantReadWriteLock = new ReentrantReadWriteLock();\n\t/**\n\t * 默认有一万张票\n\t */\n\tstatic {\n\t\tfor (int i = 0; i \u003c 10000; i++) {\n\t\t\tmTicketBeans.add(new TicketBean(i + 1));\n\t\t}\n\t}\n\n\tprivate TicketServer() {\n\t\tmRandom = new Random();\n\t}\n\n\t// 不加锁危险,\n\t// public List\u003cTicketBean\u003e queryTicket() {\n\t// return mTicketBeans;\n\t// }\n\t// 共享方式查询票数\n\tpublic int queryTicketNumber() {\n\t\tReentrantReadWriteLock.ReadLock mReadLock = mReentrantReadWriteLock.readLock();// 获取读取lock\n\t\tmReadLock.lock();// lock , 读是共享, 也就是 读的时候 其他线程依然可进这段代码进行查询票数\n\t\ttry {\n\t\t\ttry {\n\t\t\t\tThread.sleep(10);// 睡一小会\n\t\t\t} catch (InterruptedException e) {\n\t\t\t\te.printStackTrace();\n\t\t\t}\n\t\t\tSystem.out.println(\" queryTicketNumber  current size:\" + mTicketBeans.size() + \"   thread:\"\n\t\t\t\t\t+ Thread.currentThread().getName());\n\t\t\treturn mTicketBeans.size();\n\t\t} finally {\n\t\t\tmReadLock.unlock();// 一定别忘了unLock 否则出现死锁 具体请查看AQS源码\n\n\t\t}\n\n\t}\n\n\t//单例模式\n\tpublic static TicketServer getInstance() {\n\t\twhile (true) {\n\t\t\tmTicketServer = ATOMIC_REFERENCE.get();\n\t\t\tif (mTicketServer != null)\n\t\t\t\treturn mTicketServer;\n\t\t\tmTicketServer = new TicketServer();\n\t\t\tif (ATOMIC_REFERENCE.compareAndSet(null, mTicketServer))\n\t\t\t\treturn ATOMIC_REFERENCE.get();\n\t\t}\n\n\t}\n\t//买票\n\tpublic TicketBean buyTicket(String name, double price) {\n\t\tReentrantReadWriteLock.WriteLock mWriteLock = mReentrantReadWriteLock.writeLock();\n\t\tint position = -1;\n\t\tmWriteLock.lock();\n\t\ttry {\n\t\t\tint size = mTicketBeans.size();//简单查询一下票的数量\n\t\t\tif (size \u003c= 0) {\n\t\t\t\treturn null;//没有票了\n\t\t\t} else {\n\t\t\t\tposition = mRandom.nextInt(size);//随机一张票\n\t\t\t}\n\t\t\tTicketBean mTicketBean = mTicketBeans.get(position);\n\t\t\tif (price \u003c mTicketBean.getTicketMoney()) {//简单的判断当前票的价格\n\t\t\t\treturn null;\n\t\t\t}\n\t\t\tmTicketBeans.remove(position);//移除票\n\t\t\tmTicketBean.setTicketBelong(name);//设置票的所属者\n\t\t\tmTicketBean.setTicketKey(Thread.currentThread().getName());\n\t\t\tSystem.out.println(\" buy  current size:\" + mTicketBeans.size() + \"   Thread:\" + Thread.currentThread());\n\t\t\treturn mTicketBean;\n\t\t} finally {\n\t\t\tmWriteLock.unlock();//释放写锁\n\t\t}\n\t}\n\n\t}\n\n```\n\n可以看出查询和买票用了不同锁,共享锁大大提升程序性能. 能避免独占锁尽量避免独占锁\n\n\n\n以下是模拟客户端\n\n```\n\t//@author cenxiaozhong\npublic class Clients {\n    public static void main(String[] args) {\n    \t//线程池\n        ExecutorService mExecutorService = Executors.newCachedThreadPool();\n        TicketServer mTicketServer = TicketServer.getInstance();\n        //线程的计数器\n        CountDownLatch mCountDownLatch = new CountDownLatch(1100);\n        //记录一下当前时间\n        long startTime = System.currentTimeMillis();\n        System.out.println(\"startTime:\" + startTime);\n        //for循环启动100个线程买票\n        for (int i = 0; i \u003c 100; i++) {\n            mExecutorService.execute(() -\u003e {\n                while (true) {\n                    sleep(50);//睡一会提高并发量\n                    TicketBean mTicketBean = mTicketServer.buyTicket(Thread.currentThread().getName(), 800);\n                    if (mTicketBean != null) {\n                        continue;\n                    } else {\n                        break;\n                    }\n                }\n                try {//减一\n                    mCountDownLatch.countDown();\n                } catch (Exception e) {\n                    e.printStackTrace();\n                }\n            });\n        }\n\n        \t//启动一千个线程查询还有多少票\n        for (int i = 0; i \u003c 1000; i++) {\n            mExecutorService.execute(() -\u003e {\n                sleep(5);//睡一小会\n                int member = mTicketServer.queryTicketNumber();//查询票\n                try {\n                \t//线程减一\n                    mCountDownLatch.countDown();\n                } catch (Exception e) {\n                    e.printStackTrace();\n                }\n            });\n\n        }\n        try {\n        \t//主线程等待   知道1100个线程执行完毕后唤醒\n            mCountDownLatch.await();\n            //关闭线程池\n            mExecutorService.shutdown();\n        } catch (InterruptedException e) {\n            e.printStackTrace();\n        }\n        long endTime = System.currentTimeMillis();\n        System.out.println(\"endTime:\" + endTime + \"      extra:\" + (endTime - startTime));\n    }\n    public static void sleep(long time) {\n        try {\n            Thread.sleep(time);\n        } catch (Exception e) {\n            e.printStackTrace();\n        }\n    }\n\t}\n\n```\n\n#### 执行结果\n\n\t\t执行环境Mac os 10.12.2,四核 i7 16G运存\n\t\t\n\t\tendTime:1486803316222      extra:6170//使用了共享锁\n\t\tendTime:1486805339097      extra:17140//使用了独占锁\n\t\t\n\t\t可以看出来共享锁比独占锁效率高出了接近三倍, 其实不止, 会随之查询并发数增大而不同,很大一\n\t\t个原因是因为独占锁进行查询的时候会排斥所有线程.\n\t\t\n\n* 在高并发情况不使用对象池(享元模式).享元模式对资源重新利用,用空间换时间, 提高了性能.但是在并发情况反而带来开销.\n\n\n```\n\t * Return a new Message instance from the global pool. Allows us to\n     * avoid allocating new objects in many cases.\n     */\n    public static Message obtain() {\n        synchronized (sPoolSync) {\n            if (sPool != null) {\n                Message m = sPool;\n                sPool = m.next;\n                m.next = null;\n                m.flags = 0; // clear in-use flag\n                sPoolSize--;\n                return m;\n            }\n        }\n        return new Message();\n    }\n```\n\n以上可以看出从对象池获取对象是必须要加锁,不加锁会出现不同线程拿到相同对象Message,这是不允许的,加锁意味着在多线程访问中会出现阻塞, 阻塞,唤醒等开销足以new 出数百个Message对象了.所以在并发频率很高时候不使用对象池.\n\n\n* 等等.\n\n\n\n\n \n\n\t\t\n\t\t\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fjustson%2Fpratical","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fjustson%2Fpratical","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fjustson%2Fpratical/lists"}